mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-22 08:37:36 +00:00
Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
080b7671de | ||
|
|
dcfe1ec7bd | ||
|
|
ceace24a3e | ||
|
|
19e1357a06 | ||
|
|
4b381ac6a1 | ||
|
|
07d48e1ffb | ||
|
|
9fdcb8da41 | ||
|
|
ba61ac6730 | ||
|
|
94b9133910 | ||
|
|
6f8c5ae0aa | ||
|
|
60673aaef3 | ||
|
|
651529d64e | ||
|
|
532f25d0d4 | ||
|
|
643c4c3500 | ||
|
|
9d531435b4 | ||
|
|
5b9dc35eec | ||
|
|
1814dd9799 | ||
|
|
1e1cd042ea | ||
|
|
02566f6219 | ||
|
|
f488aa3f79 | ||
|
|
97457ec7a3 | ||
|
|
d62049aab4 | ||
|
|
096ac95773 | ||
|
|
4cc9b90f27 | ||
|
|
990463aa9f | ||
|
|
cd39063622 | ||
|
|
4c944a86d7 | ||
|
|
97e833a297 | ||
|
|
b8aa08f260 | ||
|
|
3f0118022b | ||
|
|
6f76c31ca7 | ||
|
|
f90beeb9b8 | ||
|
|
4b9a3d80fc | ||
|
|
1316576720 | ||
|
|
2f0c861af0 | ||
|
|
4d94777d4d | ||
|
|
46ebde65e9 | ||
|
|
932c12ad1d | ||
|
|
6f655dcbab | ||
|
|
ab60f08c69 | ||
|
|
60ed84ecd1 | ||
|
|
8aebed170a | ||
|
|
14148a0ea4 | ||
|
|
7f51fbd878 | ||
|
|
cbdd1f66da | ||
|
|
788eb0fa29 | ||
|
|
58fcad77a8 | ||
|
|
c555559ac1 | ||
|
|
e3a3fa7108 | ||
|
|
6238af397e | ||
|
|
7ffd75faf6 | ||
|
|
1f8ab95695 | ||
|
|
62dc1f7696 | ||
|
|
66f10ab7bf | ||
|
|
1ca8f63a79 | ||
|
|
03bcdd580d | ||
|
|
cc502f7e5f | ||
|
|
64ce8771c0 | ||
|
|
702a73d91a | ||
|
|
991b727eb8 | ||
|
|
ba342ce227 | ||
|
|
1464545665 | ||
|
|
fecacc9e68 | ||
|
|
1f73b4806b | ||
|
|
da2ce02596 | ||
|
|
25f35dcfb8 | ||
|
|
717f81ce08 | ||
|
|
5fdfcc7a80 | ||
|
|
0530c8e512 | ||
|
|
1d8cf4564b | ||
|
|
7e88f6bb18 | ||
|
|
af592fb779 | ||
|
|
7fc3f68007 | ||
|
|
cdcbb0047e | ||
|
|
a1956a7522 | ||
|
|
55bc7fd403 | ||
|
|
3049b1e684 | ||
|
|
1f36a7aff8 | ||
|
|
eb0e26b974 | ||
|
|
6f2b0e7833 | ||
|
|
f153255942 | ||
|
|
fef2fad76f | ||
|
|
f30d06ea34 | ||
|
|
dfa44e94e8 | ||
|
|
b81e10a461 | ||
|
|
65c93b69ed | ||
|
|
b073b052d3 |
@@ -112,11 +112,12 @@ jobs:
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
GITHUB_ACTIONS=false bun turbo test --concurrency=3
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -92,6 +92,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -111,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -214,7 +215,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +251,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +278,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +295,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +319,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +339,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,10 +407,10 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-context-menu": "5.0.0",
|
||||
"electron-log": "^5",
|
||||
"electron-updater": "6.8.9",
|
||||
"lighthouse": "13.4.1",
|
||||
@@ -436,7 +437,7 @@
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"electron": "44.4.3",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
@@ -455,7 +456,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +528,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +541,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +555,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +609,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +639,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +654,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +678,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +699,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +721,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +756,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +776,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +810,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +829,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +875,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +889,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +924,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +959,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +992,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1033,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3880,13 +3881,13 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
|
||||
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
|
||||
|
||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
|
||||
|
||||
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
|
||||
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
|
||||
|
||||
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
"x86_64-linux": "sha256-8hc0Typ9cA1NDpToM0Pq7q3AutSp+I80Sakthq10F4c=",
|
||||
"aarch64-linux": "sha256-7bzI4zWOdxuoMdMHMuqIAOgnuzWiHmpdCMYCYPbs+3c=",
|
||||
"aarch64-darwin": "sha256-EWDHUSVH2AjsNvoKvzC392H6GjCWVh07cOg2x0mAsng=",
|
||||
"x86_64-darwin": "sha256-7b2BRdRRVG+PMCSd/cm4E+slziwWIKLVttYkotdSrK4="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -29,6 +29,47 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Experimental evaluation
|
||||
|
||||
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
|
||||
isolated under an experimental entrypoint and provider namespace while the contract evolves:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
import { TypeSafeAI } from "@opencode/ai/providers"
|
||||
|
||||
const model = TypeSafeAI.configure().experimental.evaluation("jev-latest")
|
||||
|
||||
const program = Evaluation.evaluate({
|
||||
model,
|
||||
state: "I was charged twice. Please refund the duplicate payment.",
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs prompt attention", "Blocking revenue"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Effect.runPromise(program.pipe(Effect.provide(EvaluationClient.fetchLayer)))
|
||||
|
||||
console.log(response.answers.department.choice)
|
||||
console.log(response.answers.refund.probability)
|
||||
```
|
||||
|
||||
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
|
||||
`OPENCODE_API_KEY`. The common API uses `boolean`; System One routes lower it to native `noul`.
|
||||
Choice and score confidence plus score legends remain available in provider metadata, and the
|
||||
provider's rounded probabilities are returned unchanged.
|
||||
|
||||
## Alibaba Cloud Model Studio
|
||||
|
||||
`Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export { EvaluationClient } from "./experimental/evaluation-client.js"
|
||||
export {
|
||||
BooleanAnswer,
|
||||
BooleanQuestion,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
Evaluation,
|
||||
EvaluationAnswer,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationModelSchema,
|
||||
EvaluationQuestion,
|
||||
EvaluationRequest,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
} from "./experimental/evaluation.js"
|
||||
export type {
|
||||
AnswerFor,
|
||||
AnswersFor,
|
||||
EvaluationModelOptions,
|
||||
EvaluationOptions,
|
||||
EvaluationQuestions,
|
||||
EvaluationRequestFor,
|
||||
EvaluationRequestInput,
|
||||
EvaluationResponseFor,
|
||||
EvaluationRoute,
|
||||
} from "./experimental/evaluation.js"
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "../route/executor.js"
|
||||
import { mergeHttpOptions, type AIError } from "../schema/index.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import {
|
||||
type EvaluationOptions,
|
||||
type EvaluationQuestions,
|
||||
type EvaluationRequestFor,
|
||||
type EvaluationResponseFor,
|
||||
} from "./evaluation.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/Experimental/EvaluationClient") {}
|
||||
|
||||
export const evaluate = <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service> =>
|
||||
Effect.flatMap(Service, (client) => client.evaluate(request))
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
evaluate: (request) =>
|
||||
request.model.route.evaluate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
layer,
|
||||
fetchLayer,
|
||||
evaluate,
|
||||
} as const
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "../schema/index.js"
|
||||
import { EvaluationClient, Service, type Execute } from "./evaluation-client.js"
|
||||
|
||||
export const EvaluationInput = Schema.Union([Schema.String, Schema.JsonObject, Schema.Array(Schema.Json)])
|
||||
export type EvaluationInput = Schema.Schema.Type<typeof EvaluationInput>
|
||||
|
||||
const EvaluationCriterion = Schema.NullOr(EvaluationInput)
|
||||
const ChoiceCriteria = Schema.Record(Schema.String, EvaluationCriterion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Choice criteria must be a nonempty option map",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ChoiceQuestion = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: ChoiceCriteria,
|
||||
})
|
||||
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>
|
||||
|
||||
export const ScoreQuestion = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.Array(EvaluationCriterion).check(Schema.isMinLength(2)),
|
||||
})
|
||||
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>
|
||||
|
||||
export const BooleanQuestion = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(EvaluationCriterion),
|
||||
false: Schema.optional(EvaluationCriterion),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BooleanQuestion = Schema.Schema.Type<typeof BooleanQuestion>
|
||||
|
||||
export const EvaluationQuestion = Schema.Union([ChoiceQuestion, ScoreQuestion, BooleanQuestion]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationQuestion = Schema.Schema.Type<typeof EvaluationQuestion>
|
||||
export type EvaluationQuestions = Readonly<Record<string, EvaluationQuestion>>
|
||||
const EvaluationQuestions = Schema.Record(Schema.String, EvaluationQuestion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Evaluation questions must be a nonempty map",
|
||||
}),
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
|
||||
export const ChoiceAnswer = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
|
||||
|
||||
export const ScoreAnswer = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
|
||||
|
||||
export const BooleanAnswer = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
probability: Probability,
|
||||
})
|
||||
export type BooleanAnswer = Schema.Schema.Type<typeof BooleanAnswer>
|
||||
|
||||
export const EvaluationAnswer = Schema.Union([ChoiceAnswer, ScoreAnswer, BooleanAnswer]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationAnswer = Schema.Schema.Type<typeof EvaluationAnswer>
|
||||
|
||||
export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
|
||||
readonly type: "choice"
|
||||
readonly criteria: infer Criteria
|
||||
}
|
||||
? {
|
||||
readonly type: "choice"
|
||||
readonly choice: Extract<keyof Criteria, string>
|
||||
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
|
||||
}
|
||||
: Question extends { readonly type: "score" }
|
||||
? ScoreAnswer
|
||||
: BooleanAnswer
|
||||
|
||||
export type AnswersFor<Questions extends EvaluationQuestions> = {
|
||||
readonly [ID in keyof Questions]: AnswerFor<Questions[ID]>
|
||||
}
|
||||
|
||||
export type EvaluationOptions = Record<string, unknown>
|
||||
|
||||
export interface EvaluationRoute<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: string
|
||||
readonly evaluate: <const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
execute: Execute,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class EvaluationModel<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: EvaluationModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends EvaluationOptions = EvaluationOptions>(input: EvaluationModel.MakeInput<Options>) {
|
||||
return new EvaluationModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace EvaluationModel {
|
||||
export interface Input<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends EvaluationOptions = EvaluationOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const EvaluationModelSchema = Schema.declare(
|
||||
(value): value is EvaluationModel => value instanceof EvaluationModel,
|
||||
{
|
||||
expected: "Evaluation.Model",
|
||||
},
|
||||
)
|
||||
|
||||
export class EvaluationRequest extends Schema.Class<EvaluationRequest>("Evaluation.Request")({
|
||||
model: EvaluationModelSchema,
|
||||
state: EvaluationInput,
|
||||
questions: EvaluationQuestions,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _EvaluationRequest: void
|
||||
}
|
||||
|
||||
export type EvaluationModelOptions<Model> = Model extends EvaluationModel<infer Options> ? Options : never
|
||||
|
||||
export type EvaluationRequestFor<
|
||||
Options extends EvaluationOptions = EvaluationOptions,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<EvaluationRequest, "model" | "questions" | "options"> & {
|
||||
readonly model: EvaluationModel<Options>
|
||||
readonly questions: Questions
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type EvaluationRequestInput<
|
||||
Model extends object = EvaluationModel,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<ConstructorParameters<typeof EvaluationRequest>[0], "model" | "questions" | "options" | "http"> & {
|
||||
readonly model: Model
|
||||
readonly questions: Questions
|
||||
readonly options?: NoInfer<EvaluationModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends EvaluationModel<EvaluationModelOptions<Model>> ? unknown : never)
|
||||
|
||||
export class EvaluationRounding extends Schema.Class<EvaluationRounding>("Evaluation.Rounding")({
|
||||
probabilityDecimals: Schema.optional(Schema.Int),
|
||||
scoreDecimals: Schema.optional(Schema.Int),
|
||||
}) {}
|
||||
|
||||
export class EvaluationResponse extends Schema.Class<EvaluationResponse>("Evaluation.Response")({
|
||||
model: ModelID,
|
||||
answers: Schema.Record(Schema.String, EvaluationAnswer),
|
||||
usage: Schema.optional(Usage),
|
||||
rounding: Schema.optional(EvaluationRounding),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export type EvaluationResponseFor<Questions extends EvaluationQuestions> = Omit<EvaluationResponse, "answers"> & {
|
||||
readonly answers: AnswersFor<Questions>
|
||||
}
|
||||
|
||||
export function request<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): EvaluationRequestFor<EvaluationModelOptions<Model>, Questions>
|
||||
export function request(input: EvaluationRequest): EvaluationRequest
|
||||
export function request(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
if (input instanceof EvaluationRequest) return input
|
||||
return new EvaluationRequest({
|
||||
...input,
|
||||
model: input.model as unknown as EvaluationModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function evaluate<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
|
||||
export function evaluate(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
|
||||
export function evaluate(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof EvaluationRequest ? input : request(input)),
|
||||
catch: (cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.flatMap((request) =>
|
||||
EvaluationClient.evaluate(request as EvaluationRequestFor<EvaluationOptions, EvaluationQuestions>),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Evaluation = {
|
||||
request,
|
||||
evaluate,
|
||||
} as const
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
BooleanAnswer,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
type EvaluationResponseFor,
|
||||
type EvaluationRoute,
|
||||
} from "./evaluation.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
Usage,
|
||||
mergeJsonRecords,
|
||||
} from "../schema/index.js"
|
||||
|
||||
const Noul = Schema.Struct({
|
||||
type: Schema.Literal("noul"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
false: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const Question = Schema.Union([
|
||||
ChoiceQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x.criteria).length <= 255, {
|
||||
message: "System One Choice questions support at most 255 options",
|
||||
}),
|
||||
),
|
||||
ScoreQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => x.criteria.length <= 10, {
|
||||
message: "System One Score questions support at most 10 levels",
|
||||
}),
|
||||
),
|
||||
Noul,
|
||||
])
|
||||
const Request = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
state: EvaluationInput,
|
||||
questions: Schema.Record(Schema.String, Question),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Probability })
|
||||
const Choice = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Score = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
legend: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const NativeUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const encode = Schema.encodeUnknownEffect(Schema.fromJsonString(Request))
|
||||
const exact = (x: Readonly<Record<string, unknown>>, keys: ReadonlyArray<string>) =>
|
||||
Object.keys(x).length === keys.length && keys.every((key) => Object.hasOwn(x, key))
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export const model = (cfg: ModelInput) => {
|
||||
const route: EvaluationRoute = {
|
||||
id: "system-one",
|
||||
evaluate: (req, send) =>
|
||||
Effect.gen(function* () {
|
||||
const url = new URL(`${cfg.baseURL.replace(/\/$/, "")}/systemone`)
|
||||
Object.entries(req.http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
|
||||
const body = yield* encode({
|
||||
...mergeJsonRecords(req.options, req.http?.body),
|
||||
model: req.model.id,
|
||||
state: req.state,
|
||||
questions: Object.fromEntries(
|
||||
Object.entries(req.questions).map(([id, x]) => [id, x.type === "boolean" ? { ...x, type: "noul" } : x]),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new AIError({ reason: new InvalidRequestError({ message: cause.message, cause }) }),
|
||||
),
|
||||
)
|
||||
const headers = yield* Auth.toEffect(cfg.auth)({
|
||||
request: req,
|
||||
method: "POST",
|
||||
url: url.toString(),
|
||||
body,
|
||||
headers: Headers.fromInput({ ...cfg.headers, ...req.http?.headers }),
|
||||
})
|
||||
const res = yield* send(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(body, "application/json"),
|
||||
),
|
||||
)
|
||||
const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers })
|
||||
const fail = (message: string, cause: unknown, body?: string) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route: route.id, message, body, http, cause }) })
|
||||
const text = yield* res.text.pipe(
|
||||
Effect.mapError((cause) => fail("Failed to read the System One response", cause)),
|
||||
)
|
||||
const entries = Object.entries(req.questions)
|
||||
const output = Schema.Struct({
|
||||
model: Schema.String,
|
||||
answers: Schema.Struct(
|
||||
Object.fromEntries(
|
||||
entries.map(([id, question]) => {
|
||||
if (question.type === "boolean") return [id, NoulAnswer]
|
||||
if (question.type === "choice") {
|
||||
const keys = Object.keys(question.criteria)
|
||||
return [
|
||||
id,
|
||||
Choice.pipe(
|
||||
Schema.refine(
|
||||
(x): x is typeof x =>
|
||||
Object.hasOwn(question.criteria, x.choice) && exact(x.probabilities, keys),
|
||||
{ message: `Question "${id}" returned an invalid choice answer` },
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
const keys = question.criteria.map((_, index) => String(index))
|
||||
return [
|
||||
id,
|
||||
Score.pipe(
|
||||
Schema.refine(
|
||||
(x): x is typeof x =>
|
||||
x.score >= 0 && x.score <= question.criteria.length - 1 && exact(x.probabilities, keys),
|
||||
{ message: `Question "${id}" returned an invalid score answer` },
|
||||
),
|
||||
),
|
||||
]
|
||||
}),
|
||||
) as Record<string, Schema.Codec<unknown>>,
|
||||
),
|
||||
usage: Schema.optional(NativeUsage),
|
||||
})
|
||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(output))(text).pipe(
|
||||
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
|
||||
)
|
||||
|
||||
const confidence: Record<string, number> = {}
|
||||
const legend: Record<string, Record<string, Schema.Json>> = {}
|
||||
const answers = Object.fromEntries(
|
||||
entries.map(([id, question]) => {
|
||||
const answer = data.answers[id]
|
||||
if (question.type === "boolean")
|
||||
return [
|
||||
id,
|
||||
{ type: "boolean", probability: (answer as typeof NoulAnswer.Type).noul } satisfies BooleanAnswer,
|
||||
]
|
||||
if (question.type === "choice") {
|
||||
const value = answer as typeof Choice.Type
|
||||
if (value.confidence !== undefined) confidence[id] = value.confidence
|
||||
return [
|
||||
id,
|
||||
{ type: "choice", choice: value.choice, probabilities: value.probabilities } satisfies ChoiceAnswer,
|
||||
]
|
||||
}
|
||||
const value = answer as typeof Score.Type
|
||||
if (value.confidence !== undefined) confidence[id] = value.confidence
|
||||
if (value.legend !== undefined) legend[id] = value.legend
|
||||
return [id, { type: "score", score: value.score, probabilities: value.probabilities } satisfies ScoreAnswer]
|
||||
}),
|
||||
)
|
||||
const meta = {
|
||||
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
|
||||
...(Object.keys(legend).length === 0 ? {} : { legend }),
|
||||
}
|
||||
return new EvaluationResponse({
|
||||
model: ModelID.make(data.model),
|
||||
answers,
|
||||
usage: data.usage
|
||||
? new Usage({
|
||||
inputTokens: data.usage.input_tokens,
|
||||
outputTokens: data.usage.output_tokens,
|
||||
totalTokens:
|
||||
data.usage.input_tokens === undefined && data.usage.output_tokens === undefined
|
||||
? undefined
|
||||
: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0),
|
||||
providerMetadata: { [cfg.providerMetadataKey]: data.usage },
|
||||
})
|
||||
: undefined,
|
||||
rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }),
|
||||
providerMetadata: Object.keys(meta).length === 0 ? undefined : { [cfg.providerMetadataKey]: meta },
|
||||
}) as EvaluationResponseFor<typeof req.questions>
|
||||
}),
|
||||
}
|
||||
return EvaluationModel.make({ id: cfg.id, provider: cfg.provider, route, http: cfg.http })
|
||||
}
|
||||
|
||||
export const SystemOne = { model } as const
|
||||
@@ -73,11 +73,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
)
|
||||
if (event.type === "error") {
|
||||
terminal = true
|
||||
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
|
||||
),
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
|
||||
|
||||
@@ -108,7 +108,7 @@ const incremental = (
|
||||
return input.slice(baseline.length)
|
||||
}
|
||||
|
||||
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
|
||||
|
||||
const rejected = (
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -333,53 +333,13 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
|
||||
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorObject = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
|
||||
Schema.decodeTo(OpenResponsesErrorObject, {
|
||||
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("error"),
|
||||
status: Schema.optional(Schema.Number),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||
|
||||
export const decodeKnownErrorEvent = (event: Event) =>
|
||||
decodeWebSocketErrorEvent({
|
||||
...event,
|
||||
status: typeof event.status === "number" ? event.status : undefined,
|
||||
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||
headers: ProviderShared.isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).filter(
|
||||
(entry): entry is [string, string | number | boolean] =>
|
||||
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
const asText = (value: unknown) =>
|
||||
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -400,31 +360,18 @@ export const Event = Schema.StructWithRest(
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
output: Schema.optional(Schema.Array(StreamItem)),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
code: Schema.optional(Schema.Unknown),
|
||||
message: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
status: Schema.optional(Schema.Unknown),
|
||||
status_code: Schema.optional(Schema.Unknown),
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
@@ -433,16 +380,15 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
|
||||
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
|
||||
* failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
@@ -1422,22 +1368,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
|
||||
export const errorDetail = (event: Event) => {
|
||||
const raw = event.error ?? event.response?.error
|
||||
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
|
||||
return {
|
||||
message: asText(event.message) ?? asText(nested?.message),
|
||||
code: asText(event.code) ?? asText(nested?.code),
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
|
||||
// visible; fall back to the raw frame rather than a generic message when neither decodes.
|
||||
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const detail = errorDetail(event)
|
||||
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
@@ -1520,18 +1465,7 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
state.id,
|
||||
`${state.name} returned a malformed error event`,
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
|
||||
)
|
||||
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const { serviceTier: _, ...body } = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, contents, labels }
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
|
||||
@@ -24,8 +24,10 @@ export * as Moonshot from "./moonshot.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
export * as OpenRouter from "./openrouter.js"
|
||||
export * as TogetherAI from "./togetherai.js"
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
export * as XAI from "./xai.js"
|
||||
export * as ZAI from "./zai.js"
|
||||
export * as ZAICodingPlan from "./zai-coding-plan.js"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("opencode")
|
||||
const baseURL = "https://opencode.ai/zen/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const configure = (input: Options = {}) => {
|
||||
const evaluation = (modelID: string | ModelID) =>
|
||||
SystemOne.model({
|
||||
id: modelID,
|
||||
provider: id,
|
||||
providerMetadataKey: "opencode",
|
||||
auth: AuthOptions.bearer(input, "OPENCODE_API_KEY"),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return { id, experimental: { evaluation }, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const experimental = provider.experimental
|
||||
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("typesafe-ai")
|
||||
const baseURL = "https://api.typesafe.ai/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const configure = (input: Options = {}) => {
|
||||
const evaluation = (modelID: string | ModelID) =>
|
||||
SystemOne.model({
|
||||
id: modelID,
|
||||
provider: id,
|
||||
providerMetadataKey: "typesafe",
|
||||
auth: AuthOptions.bearer(input, "TYPESAFE_API_KEY"),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return { id, experimental: { evaluation }, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const experimental = provider.experimental
|
||||
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
@@ -215,7 +215,9 @@ export const fromWebSocket = (
|
||||
): Effect.Effect<WebSocketConnection, AIError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* waitOpen(ws, input)
|
||||
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
||||
// The socket pushes frames synchronously and cannot be paused, so the hand-off to the consumer
|
||||
// fiber must absorb whole read buffers. Bun delivers over a thousand small frames in one tick.
|
||||
const messages = yield* Queue.unbounded<string | Uint8Array, AIError | Cause.Done<void>>()
|
||||
|
||||
const oversized = (message: string | Uint8Array) =>
|
||||
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
|
||||
@@ -238,19 +240,7 @@ export const fromWebSocket = (
|
||||
}
|
||||
const offer = (message: string | Uint8Array) => {
|
||||
if (rejectOversized(message)) return
|
||||
if (Queue.offerUnsafe(messages, message)) return
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("WebSocket inbound queue overflow", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
Queue.offerUnsafe(messages, message)
|
||||
}
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Evaluation, EvaluationClient } from "../src/experimental.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
|
||||
describe("experimental Evaluation", () => {
|
||||
it.effect("evaluates typed questions through System One", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.evaluate({
|
||||
model: TypeSafeAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://typesafe.test/v1/",
|
||||
headers: { "x-default": "yes" },
|
||||
http: { body: { deployment: "test" }, query: { api: "v1" } },
|
||||
}).experimental.evaluation("jev-latest"),
|
||||
state: { ticket: "Please refund the duplicate charge." },
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs attention", "Blocking"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
options: { trace: { enabled: true } },
|
||||
http: { body: { request_metadata: "value" }, headers: { "x-request": "yes" }, query: { trace: "1" } },
|
||||
})
|
||||
|
||||
expect(response.model).toBe("jev-1.13.0")
|
||||
expect(response.answers.department).toEqual({
|
||||
type: "choice",
|
||||
choice: "billing",
|
||||
probabilities: { billing: 0.9, technical: 0.1 },
|
||||
})
|
||||
expect(response.answers.urgency).toEqual({
|
||||
type: "score",
|
||||
score: 1.2,
|
||||
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
|
||||
})
|
||||
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
|
||||
expect(response.usage?.totalTokens).toBe(36)
|
||||
expect(response.providerMetadata).toEqual({
|
||||
typesafe: {
|
||||
confidence: { department: 0.8, urgency: 0.6 },
|
||||
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://typesafe.test/v1/systemone?api=v1&trace=1")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
deployment: "test",
|
||||
request_metadata: "value",
|
||||
trace: { enabled: true },
|
||||
model: "jev-latest",
|
||||
state: { ticket: "Please refund the duplicate charge." },
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs attention", "Blocking"],
|
||||
},
|
||||
refund: { type: "noul", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
model: "jev-1.13.0",
|
||||
answers: {
|
||||
department: {
|
||||
type: "choice",
|
||||
choice: "billing",
|
||||
probabilities: { billing: 0.9, technical: 0.1 },
|
||||
confidence: 0.8,
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
score: 1.2,
|
||||
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
|
||||
legend: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" },
|
||||
confidence: 0.6,
|
||||
},
|
||||
refund: { type: "noul", noul: 0.97 },
|
||||
},
|
||||
usage: { input_tokens: 30, output_tokens: 6 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("configures the OpenCode Zen System One endpoint", () =>
|
||||
Evaluation.evaluate({
|
||||
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
|
||||
"jev-1.13",
|
||||
),
|
||||
state: "hello",
|
||||
questions: { greeting: { type: "boolean", instructions: "Is this a greeting?" } },
|
||||
}).pipe(
|
||||
Effect.tap((response) =>
|
||||
Effect.sync(() => {
|
||||
expect(response.answers.greeting.probability).toBe(0.99)
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
opencode: { input_tokens: 10, output_tokens: 2 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(input.request.url).toBe("https://zen.test/v1/systemone")
|
||||
expect(input.request.headers.authorization).toBe("Bearer zen-key")
|
||||
return Effect.succeed(
|
||||
input.respond(
|
||||
JSON.stringify({
|
||||
model: "jev-1.13.0",
|
||||
answers: { greeting: { type: "noul", noul: 0.99 } },
|
||||
usage: { input_tokens: 10, output_tokens: 2 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed questions before network I/O", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Evaluation.evaluate({
|
||||
model: TypeSafeAI.experimental.evaluation("jev-latest"),
|
||||
state: "hello",
|
||||
questions: { score: { type: "score", instructions: "How much?", criteria: ["only"] } },
|
||||
}).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(dynamicResponse(() => Effect.die("invalid evaluation reached the network"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient, EvaluationModel, type EvaluationRoute } from "../src/experimental.js"
|
||||
import type { Service } from "../src/experimental/evaluation-client.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
const model = TypeSafeAI.configure({ apiKey: "test" }).experimental.evaluation("jev-latest")
|
||||
const request = Evaluation.request({
|
||||
model,
|
||||
state: { ticket: "refund" },
|
||||
questions: {
|
||||
topic: {
|
||||
type: "choice",
|
||||
instructions: "Which team?",
|
||||
criteria: { billing: null, support: { includes: ["help"] } },
|
||||
},
|
||||
severity: { type: "score", instructions: "How severe?", criteria: ["Low", "High"] },
|
||||
refund: { type: "boolean", instructions: "Refund?" },
|
||||
},
|
||||
})
|
||||
|
||||
const result = EvaluationClient.evaluate(request)
|
||||
type Result = Success<typeof result>
|
||||
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
|
||||
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
|
||||
void (true satisfies Choice)
|
||||
void (true satisfies ClientRequirements)
|
||||
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.evaluate({
|
||||
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
|
||||
state: ["hello"],
|
||||
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
|
||||
})
|
||||
response.answers.greeting.probability satisfies number
|
||||
// @ts-expect-error Boolean answers do not contain a selected choice.
|
||||
response.answers.greeting.choice
|
||||
// @ts-expect-error Unknown question IDs are not exposed.
|
||||
response.answers.missing
|
||||
})
|
||||
|
||||
declare const route: EvaluationRoute<{ readonly temperature?: number }>
|
||||
const custom = EvaluationModel.make({ id: "custom", provider: "custom", route })
|
||||
Evaluation.evaluate({
|
||||
model: custom,
|
||||
state: "hello",
|
||||
questions: { ok: { type: "boolean", instructions: "OK?" } },
|
||||
options: { temperature: 0.5 },
|
||||
})
|
||||
// @ts-expect-error Selected evaluation models retain their request option types.
|
||||
Evaluation.evaluate({
|
||||
model: custom,
|
||||
state: "hello",
|
||||
questions: { ok: { type: "boolean", instructions: "OK?" } },
|
||||
options: { temperature: "high" },
|
||||
})
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
CloudflareWorkersAI,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
OpenCodeZen,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
TypeSafeAI,
|
||||
XAI,
|
||||
} from "@opencode/ai/providers"
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from "@opencode/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode/ai/protocols/anthropic-messages"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
|
||||
describe("public exports", () => {
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
@@ -37,6 +40,9 @@ describe("public exports", () => {
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
expect(Evaluation.evaluate).toBeFunction()
|
||||
expect(EvaluationClient.layer).toBeDefined()
|
||||
expect(EvaluationClient.fetchLayer).toBeDefined()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
@@ -66,6 +72,8 @@ describe("public exports", () => {
|
||||
expect(CloudflareWorkersAI.configure).toBeFunction()
|
||||
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(TypeSafeAI.experimental.evaluation).toBeFunction()
|
||||
expect(OpenCodeZen.experimental.evaluation).toBeFunction()
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
"recordedAt": "2026-09-22T03:46:29.725Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_28936\"},\"thoughtSignature\": \"AY89a1/MVeBxtwu9l/96clFobOrrd7Q5MTf5o8/A22fc++1EsFYVeCFx9WCGmJ+D3yW3FWlRXKtJECYfprSoxixCSOYBNiV9g8/IkejbAxF26k1vxTsHkoqts0O2s8GqqBSRAavJfKQ/taTRbPuIq+b+RKZ8SDJFoWDnFjXpdS5S164uJTjmgs4ALd8oB1oQRycMsTbAY51TmkcNUnhARpjKQfqiizz4KfSRFjx7xDnza0v4jj73m2GV86eLDhVT7trIT+Dl5DsJt6RBLOE2Amse54mcpIbsYaANlY1YDU6FKuUBqnuwpl0OfhMXwWuYMogv1tH/0rTXsqNwmHyt/b9ZZXqYCdFXpMGt8udmoZ0d\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 93,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 38},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+8
-4
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
"recordedAt": "2026-09-22T03:46:30.691Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -14,14 +18,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18aVI8Uxr2VaOI822u0DyuQdy4B00uhLnjqYb5Qb6Mkscccm018knLtYThB5UX8dRv1VFsORSQ0Qo6Gx9RCng2AK9EPce7p\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 74,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}]},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
"recordedAt": "2026-09-22T03:46:28.840Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18msz0aRAZc4U/Zl7rEXjdTJsE0EWee1+hD2dT7h711q6uIRem7KWCLlji7JVSu2hDV1L8uFh8SGAO5jU4MBSZ+mq661EVXf9DZBF1CV+fetSpCUggurbYHWGGGvLRxjI96W0wR93EMoV/1pLHa4fgGVcFupoFsz/3n9J3wCkT4pRvHjn+hC7V11aSMI07t/OUeo1ssAziob9apuB+HUm2/EO17ph9TZvg3rydE5J0nmJ+DnYGLl5FfQJHgnnsjR5qaKs5wVNIKNA811lv/K5Iee/O6gMNXI3GAcGUvPKHMD5oQSs5B343blFUiqw47H1WZ9KWBoLXhVcnss6hLPmpDchInVhr3ObAHQhZIaBDBnhQR6nuc5BtgoFnwWw3w5G1yNNFt3qT3c75Rd/8NamdtveyskDbrHxQ5GkUo7ICuE/3SQG0yqWhVDjakYgVqjLG3BTKRg6I/dT5+VCeQbdxTZ+ScWkJfXYDEnlZlTjMLz2FS+B+9Zwez/1Dy/pm9H51jvuXI942kv31DfDVcTTwvKmb3SqIB9D4aomb16gDAywC7I4L/+ZwsqmcQoUvVtHGEe5NL3r6fgWtWzKht+60ZgBalmzdBfhZHd/bDXvTAX7SVpHraXjfpcdGrtv1LhU6Kr3gol94TR5Xh9dWKNhwR0s6at7MNCpj3eG9ogrWkmYSiqNeVT1ovscWvH70tDCtCNwY5sIUJVUSWdHc2OdzFxNIPkzh9OuHIn0o9Dbmup6obFoQOgi587HA/Sqvre9pDVqVMzn7w57naDmg+lsR9wS67BxbV4tVrvvfeVQAQQRft5SmSu7WCBnaWuM2/o890nE5ynFG+M2FqApJLgdfXozOguo/hROrD3WxLUyT8+FDsoCY8ky6YYTaF3OlOHpuD5Zmw4UGSdDbcEzhc6cRqBAYfMpxzN58VwNRrjG/MGTY3jrvOLJDk3sF7URa7Tn5YuRLp/2jL5YKWF2QftWiW9jl1YeORYQwNdLBuRK9L4/WOxNRWhYZToAJfwT4byh5dKzY8OnjeFcJy8RVvuRpoYeQY1P63ie/K1D3TgJEwKQXylLdPwtZRGM/P5sm2dcf5F+lRfZGufmv+Q++unrqWr6uv3IlgVGKPa8zTh+ipSaik7taKd+jertOSYkgjrrUfjC0/oyg5giuNqXh+ebIxQC7TMX8bManc5N3aomFuYXmIJ4iT/euQctq8N3gPPWmUNmrnXztdEZgpQdV2ahj+yRr1yxCriOfRo3Az3oPMPEaXxrqDc8RoWt/lOgzzp7KfBGoOVzYdvaDGr8MhsZ8Pd7HD0vZW8TNSl8R7cEK3G5EotNA+s6lF/n/RH5ewmg3cQK8bnnZ4oNjqYo3pGtw+H2rbefMMzAk4mybGe9uDqSdhWJgBzRGsMXRW+0atn9tuJTu6vPNn9asNvwXS0lcvpQskbe5sK3yhzyFzqwrJxs7Ji6yiu6kOtsQ5Girzk1835A2Zun6ZegGtQH8DbgIPdSBMkh6kypndBu4ns2gg97g9yB8XFGbv3c60sxaXPRuycl9IjBL81Yy6n6PCOBx7Pqm9xrzPn7QzgtetKxCmNUMYJvF+myiQFrMdL1w+1DD7FoZX9VNBDbNWgcfhgnL8tlHSmTb36KnScJOe8D+3n3+aDfkWpiHjrLrGJsHlaek/Ji2Cgwa0dIC6EhQitNttskIKVMa+yD6D1T6Dvz/eolnutjYwbGb3y9mtpQzmVwf2Zp2dzxYRWZk=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 285,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 277},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "jev-1.13-free",
|
||||
"tags": [
|
||||
"prefix:opencode-zen-evaluation",
|
||||
"provider:opencode",
|
||||
"protocol:system-one"
|
||||
],
|
||||
"name": "opencode-zen-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"recordedAt": "2026-09-22T03:44:57.511Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/zen/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13-free\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13-free\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1,\"probabilities\":{\"technical\":0,\"sales\":0,\"billing\":1}},\"urgency\":{\"type\":\"score\",\"score\":1.02,\"confidence\":0.95,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0,\"1\":0.97,\"2\":0.03}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "jev-latest",
|
||||
"tags": [
|
||||
"prefix:typesafe-evaluation",
|
||||
"provider:typesafe-ai",
|
||||
"protocol:system-one"
|
||||
],
|
||||
"name": "typesafe-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"recordedAt": "2026-09-22T03:44:28.626Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.typesafe.ai/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-latest\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13.0\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1.0,\"probabilities\":{\"billing\":1.0,\"sales\":0.0,\"technical\":0.0}},\"urgency\":{\"type\":\"score\",\"score\":1.01,\"confidence\":0.97,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0.0,\"1\":0.98,\"2\":0.02}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation } from "../../src/experimental.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const questions = {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this support request?",
|
||||
criteria: {
|
||||
billing: "Payments, invoices, refunds, or failed charges",
|
||||
technical: "Bugs, outages, or integrations",
|
||||
sales: "Pricing, upgrades, or new accounts",
|
||||
},
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this support request?",
|
||||
criteria: ["Can wait for normal support", "Needs prompt attention", "Actively blocking revenue"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
} as const
|
||||
|
||||
const state = "I was charged twice for the same invoice. Please refund the duplicate payment today."
|
||||
|
||||
const typesafe = recordedTests({
|
||||
prefix: "typesafe-evaluation",
|
||||
provider: "typesafe-ai",
|
||||
protocol: "system-one",
|
||||
requires: ["TYPESAFE_API_KEY"],
|
||||
metadata: { model: "jev-latest" },
|
||||
})
|
||||
|
||||
const zen = recordedTests({
|
||||
prefix: "opencode-zen-evaluation",
|
||||
provider: "opencode",
|
||||
protocol: "system-one",
|
||||
requires: ["OPENCODE_API_KEY"],
|
||||
metadata: { model: "jev-1.13-free" },
|
||||
})
|
||||
|
||||
describe("experimental Evaluation recorded", () => {
|
||||
typesafe.effect("evaluates choice score and boolean questions", () =>
|
||||
assertEvaluation(
|
||||
TypeSafeAI.configure({ apiKey: process.env.TYPESAFE_API_KEY ?? "fixture" }).experimental.evaluation("jev-latest"),
|
||||
"typesafe",
|
||||
),
|
||||
)
|
||||
|
||||
zen.effect("evaluates choice score and boolean questions", () =>
|
||||
assertEvaluation(
|
||||
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
|
||||
"jev-1.13-free",
|
||||
),
|
||||
"opencode",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const assertEvaluation = (
|
||||
model: ReturnType<typeof TypeSafeAI.experimental.evaluation>,
|
||||
metadataKey: "typesafe" | "opencode",
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.evaluate({ model, state, questions })
|
||||
expect(response.model).toStartWith("jev-")
|
||||
expect(response.answers.department.type).toBe("choice")
|
||||
expect(response.answers.department.choice).toBe("billing")
|
||||
expect(response.answers.department.probabilities?.billing).toBeGreaterThan(0.5)
|
||||
expect(response.answers.urgency.type).toBe("score")
|
||||
expect(response.answers.urgency.score).toBeGreaterThanOrEqual(0)
|
||||
expect(response.answers.urgency.score).toBeLessThanOrEqual(2)
|
||||
expect(response.answers.refund.type).toBe("boolean")
|
||||
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
|
||||
})
|
||||
@@ -114,7 +114,7 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
it.effect("preserves function call ids in lowered Vertex bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -142,15 +142,14 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||
expect(prepared.body.contents).toMatchObject([
|
||||
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
@@ -235,12 +234,23 @@ describe("Google Vertex providers", () => {
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "provider_call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -11,66 +11,78 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
for (const frame of [
|
||||
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
|
||||
{ type: "error" },
|
||||
{ type: "error", error: "Gateway failed" },
|
||||
{ type: "error", error: { code: 429, message: "slow down" } },
|
||||
{ type: "error", error: 42 },
|
||||
{ type: "error", code: 500, message: ["not", "a", "string"] },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
it.effect("reads bare WebSocket error envelopes as error events", () =>
|
||||
Effect.gen(function* () {
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
for (const error of [{ type: "api_error", message }, message]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
|
||||
type: "error",
|
||||
error: typeof error === "string" ? { message } : error,
|
||||
})
|
||||
for (const error of [{ type: "api_error", message }, message, 42]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
|
||||
}
|
||||
for (const frame of [{ error: null }, { message }]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
|
||||
type: "error",
|
||||
error: { message: "Gateway failed" },
|
||||
})
|
||||
expect(
|
||||
yield* decode(
|
||||
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { message: "Gateway failed" } },
|
||||
})
|
||||
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
|
||||
[
|
||||
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
|
||||
"server_shutting_down: Shutting down",
|
||||
"UnknownProvider",
|
||||
],
|
||||
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
|
||||
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
|
||||
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
|
||||
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
|
||||
[
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
|
||||
"Gateway failed",
|
||||
"UnknownProvider",
|
||||
],
|
||||
]
|
||||
for (const [frame, message, tag] of cases) {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
|
||||
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
|
||||
expect(error.message).toBe(message)
|
||||
expect(error.reason._tag).toBe(tag)
|
||||
expect(error.reason.body).toBe(JSON.stringify(frame))
|
||||
}
|
||||
for (const frame of [
|
||||
{ type: "error", error: 42 },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
|
||||
]) {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
|
||||
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
|
||||
expect(error.message).toBe(JSON.stringify(frame))
|
||||
expect(error.reason._tag).toBe("UnknownProvider")
|
||||
}
|
||||
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
|
||||
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
import { ImageClient } from "../src/image-client.js"
|
||||
import { EvaluationClient } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as EvaluationClientService } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as ImageClientService } from "../src/image-client.js"
|
||||
import type { Service as LLMClientService } from "../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
||||
@@ -18,7 +20,12 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
|
||||
type RecordedEnv =
|
||||
| RequestExecutorService
|
||||
| LLMClientService
|
||||
| ImageClientService
|
||||
| EvaluationClientService
|
||||
| Socket.WebSocketConstructor
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -92,6 +99,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
EvaluationClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
webSocket,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
|
||||
from storage. Desktop provides the local server it discovers or starts through native initialization.
|
||||
|
||||
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
|
||||
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
|
||||
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
|
||||
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
|
||||
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
|
||||
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
|
||||
@@ -103,7 +103,7 @@ HTTPS (or localhost) and browser permission. Saved offline servers continue to u
|
||||
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
|
||||
|
||||
```bash
|
||||
opencode pair --url https://your-machine.your-tailnet.ts.net
|
||||
opencode pair --url https://opencode.example.com
|
||||
```
|
||||
|
||||
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
|
||||
|
||||
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
url: undefined as string | undefined,
|
||||
loading: false,
|
||||
generation: 0,
|
||||
delayNavigation: false,
|
||||
pendingURL: undefined as string | undefined,
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
active: () => {
|
||||
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
},
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => undefined,
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: () => undefined,
|
||||
command: (command) => {
|
||||
setStore("error", undefined)
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
if (command.type === "navigate") {
|
||||
if (store.delayNavigation) {
|
||||
setStore("pendingURL", command.url)
|
||||
return
|
||||
}
|
||||
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
|
||||
}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
|
||||
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await toggle.click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Loading page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await root.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await address.fill("https://unsubmitted.example/")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"https://alpha.example/",
|
||||
)
|
||||
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Reload", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("")
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("selects the full URL when the address field gains focus", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
await address.pressSequentially("https://example.com/")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await address.focus()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
await address.press("ArrowRight")
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
})
|
||||
|
||||
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -17,14 +17,12 @@ const milestones = [
|
||||
"electronSpawnStarted",
|
||||
"debugEndpointReady",
|
||||
"electronStarted",
|
||||
"layersReady",
|
||||
"windowVisible",
|
||||
"serviceEnsureStarted",
|
||||
"serviceSpawnRequested",
|
||||
"serviceReady",
|
||||
"backgroundLoadingReady",
|
||||
"rendererViteConnected",
|
||||
"rendererInitializationStarted",
|
||||
"rendererInitializationReady",
|
||||
"windowVisible",
|
||||
"homeReady",
|
||||
] as const
|
||||
const phases = [
|
||||
@@ -128,14 +126,12 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
|
||||
{ name: "electronSpawnStarted", text: "starting electron app..." },
|
||||
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
|
||||
{ name: "electronStarted", text: "app starting" },
|
||||
{ name: "layersReady", text: "layers ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
|
||||
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
|
||||
{ name: "serviceReady", text: "v2 CLI background service ready" },
|
||||
{ name: "backgroundLoadingReady", text: "loading task finished" },
|
||||
{ name: "rendererViteConnected", text: "[vite] connected." },
|
||||
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
|
||||
{ name: "rendererInitializationReady", text: "server ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
]
|
||||
|
||||
async function createColdProfile() {
|
||||
@@ -404,14 +400,12 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
|
||||
electronSpawnStarted: get("electronSpawnStarted"),
|
||||
debugEndpointReady: get("debugEndpointReady"),
|
||||
electronStarted: get("electronStarted"),
|
||||
layersReady: get("layersReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
serviceEnsureStarted: get("serviceEnsureStarted"),
|
||||
serviceSpawnRequested: get("serviceSpawnRequested"),
|
||||
serviceReady: get("serviceReady"),
|
||||
backgroundLoadingReady: get("backgroundLoadingReady"),
|
||||
rendererViteConnected: get("rendererViteConnected"),
|
||||
rendererInitializationStarted: get("rendererInitializationStarted"),
|
||||
rendererInitializationReady: get("rendererInitializationReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
homeReady: get("homeReady"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,12 @@ describe("desktop startup benchmark", () => {
|
||||
["electronSpawnStarted", "starting electron app..."],
|
||||
["debugEndpointReady", "DevTools listening on ws://"],
|
||||
["electronStarted", "app starting"],
|
||||
["layersReady", "layers ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
["serviceEnsureStarted", "starting v2 background service"],
|
||||
["serviceSpawnRequested", "v2 CLI background service starting"],
|
||||
["serviceReady", "v2 CLI background service ready"],
|
||||
["backgroundLoadingReady", "loading task finished"],
|
||||
["rendererViteConnected", "[vite] connected."],
|
||||
["rendererInitializationStarted", "awaiting server ready"],
|
||||
["rendererInitializationReady", "server ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
] as const
|
||||
cases.forEach(([milestone, line]) => {
|
||||
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
|
||||
@@ -58,14 +56,12 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronSpawnStarted: 7,
|
||||
debugEndpointReady: 8,
|
||||
electronStarted: 9,
|
||||
serviceEnsureStarted: 10,
|
||||
serviceSpawnRequested: 11,
|
||||
serviceReady: 12,
|
||||
backgroundLoadingReady: 13,
|
||||
rendererViteConnected: 14,
|
||||
rendererInitializationStarted: 15,
|
||||
rendererInitializationReady: 16,
|
||||
windowVisible: 17,
|
||||
layersReady: 10,
|
||||
windowVisible: 11,
|
||||
serviceEnsureStarted: 12,
|
||||
serviceSpawnRequested: 13,
|
||||
serviceReady: 14,
|
||||
rendererViteConnected: 15,
|
||||
homeReady: commandToHomeReadyMs,
|
||||
}
|
||||
return {
|
||||
@@ -80,8 +76,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronStartup: 2,
|
||||
serviceSpawnWait: 1,
|
||||
serviceProcessStartup: 1,
|
||||
rendererStartup: commandToHomeReadyMs - 14,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 17,
|
||||
rendererStartup: commandToHomeReadyMs - 15,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 11,
|
||||
},
|
||||
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/BtwSidebar"
|
||||
const projectID = "proj_btw_sidebar"
|
||||
const sessionID = "ses_btw_sidebar"
|
||||
const otherSessionID = "ses_btw_sidebar_other"
|
||||
const title = "Side question session"
|
||||
const otherTitle = "Other side question session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "btw-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: otherSessionID,
|
||||
slug: otherSessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: otherTitle,
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsDiff: [],
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
generations.push(input)
|
||||
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
|
||||
await generated.promise
|
||||
return {
|
||||
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, otherSessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: otherSessionID },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, otherSessionID },
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
await editor.fill("/btw")
|
||||
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/btw ")
|
||||
await editor.press("Enter")
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
|
||||
expect(generations).toEqual([])
|
||||
expect(prompts).toEqual([])
|
||||
|
||||
await editor.fill("/btw how does the retry loop work?")
|
||||
await editor.press("Enter")
|
||||
|
||||
const tab = page.getByRole("tab", { name: "/btw" })
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(tab).toHaveAttribute("data-selected", "")
|
||||
generated.resolve()
|
||||
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
|
||||
expect(generations).toHaveLength(1)
|
||||
expect(generations[0]?.sessionID).toBe(sessionID)
|
||||
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
|
||||
expect(prompts).toEqual([])
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
|
||||
await expectSessionTitle(page, otherTitle)
|
||||
await editor.fill("/btw what belongs here?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
|
||||
for (const width of [1520, 1280, 1600]) {
|
||||
for (const width of [1520, 1280, 1800]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
|
||||
await page.clock.runFor(100)
|
||||
@@ -84,6 +84,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect(content).not.toHaveCSS("translate", shifted)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
@@ -112,7 +113,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "0px")
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
|
||||
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
|
||||
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
|
||||
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
|
||||
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -197,6 +197,13 @@ const Group = HttpApiGroup.make("mock")
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", {
|
||||
params: SessionParams,
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
|
||||
params: SessionParams,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface MockServerConfig {
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
|
||||
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
}
|
||||
|
||||
@@ -456,6 +457,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
},
|
||||
}
|
||||
}),
|
||||
sessionGenerate: (ctx) =>
|
||||
Effect.promise(async () => ({
|
||||
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
|
||||
text: "Side-question answer",
|
||||
},
|
||||
})),
|
||||
sessionInboxCancel: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -91,6 +91,7 @@
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3"
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-16
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,21 +107,29 @@ export function AppInterface(props: {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseClientSlashCommand } from "./client-slash-command"
|
||||
|
||||
const options = [
|
||||
{ id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const },
|
||||
{ id: "custom.btw", trigger: "custom", type: "custom" as const },
|
||||
{ id: "model.choose", trigger: "model", type: "builtin" as const },
|
||||
]
|
||||
|
||||
describe("parseClientSlashCommand", () => {
|
||||
test("parses inline and multiline arguments", () => {
|
||||
expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({
|
||||
id: "session.btw",
|
||||
input: "why this approach?",
|
||||
})
|
||||
expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({
|
||||
id: "session.btw",
|
||||
input: "why this approach?",
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts a bare argument command", () => {
|
||||
expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" })
|
||||
})
|
||||
|
||||
test("rejects prefixes, custom commands, and ordinary slash commands", () => {
|
||||
expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
type ClientSlashCommand = {
|
||||
id: string
|
||||
trigger: string
|
||||
arguments?: boolean
|
||||
type: "builtin" | "custom"
|
||||
}
|
||||
|
||||
export function parseSlashCommand(text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const separator = text.search(/\s/)
|
||||
const name = text.slice(1, separator === -1 ? undefined : separator)
|
||||
return { name, input: separator === -1 ? "" : text.slice(separator).trim() }
|
||||
}
|
||||
|
||||
export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) {
|
||||
const command = parseSlashCommand(text)
|
||||
if (!command) return
|
||||
const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name)
|
||||
if (!option) return
|
||||
return {
|
||||
id: option.id,
|
||||
input: command.input,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { useAttachmentDestination } from "./attachments/destination"
|
||||
import { parseClientSlashCommand } from "./client-slash-command"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -73,9 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter(isAttachment),
|
||||
)
|
||||
const attachments = createMemo(() => prompt.current().filter(isAttachment))
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
@@ -242,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
trigger: item.slash!,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
arguments: item.slashArguments,
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
@@ -299,6 +299,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
clear: comments.clear,
|
||||
restore: restoreHistoryComments,
|
||||
},
|
||||
clientCommand: (text) => {
|
||||
const selected = parseClientSlashCommand(slashCommands(), text)
|
||||
if (!selected) return
|
||||
return () => command.trigger(selected.id, "slash", selected.input)
|
||||
},
|
||||
})
|
||||
const controller = createComposerEditor({
|
||||
store: prompt.store,
|
||||
@@ -340,6 +345,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
if (item.kind !== "command") return
|
||||
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||
if (!selected || selected.type === "custom") return
|
||||
if (selected.arguments) return
|
||||
return () => command.trigger(selected.id, "slash")
|
||||
},
|
||||
attachments: {
|
||||
|
||||
@@ -54,14 +54,17 @@ function submitInput(
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
clientCommand,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
addToHistory: (prompt) =>
|
||||
history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
resetHistory() {},
|
||||
@@ -118,6 +121,61 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("runs a client argument command without admitting it to the session", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/btw why this approach?", start: 0, end: 23 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "diagram.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
state.context.add({ type: "file", path: "src/retry.ts" })
|
||||
const calls: string[] = []
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
throw new Error("client command must not call prompt")
|
||||
},
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
const history: string[] = []
|
||||
await submitInput(adapter, undefined, "normal", undefined, history, (text) => {
|
||||
expect(text).toBe("/btw why this approach?")
|
||||
return () => {
|
||||
calls.push("btw")
|
||||
}
|
||||
}).submit(new Event("submit"))
|
||||
|
||||
expect(calls).toEqual(["btw"])
|
||||
expect(history).toEqual([])
|
||||
expect(state.current()).toEqual([
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "diagram.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -625,12 +683,7 @@ describe("Composer submission", () => {
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => catalog,
|
||||
).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { parseSlashCommand } from "./client-slash-command"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
@@ -37,6 +38,7 @@ type ComposerSubmitInput = {
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
delivery?: (alternate: boolean) => ComposerDelivery
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
|
||||
@@ -52,15 +54,31 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
|
||||
event.preventDefault()
|
||||
|
||||
const prompt = clonePrompt(input.adapter.state.current())
|
||||
const text = submissionText(prompt)
|
||||
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
|
||||
if (clientCommand) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
try {
|
||||
clearClientCommand(input, prompt)
|
||||
await clientCommand()
|
||||
} catch (error) {
|
||||
input.notify.failed("command", error)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
return
|
||||
}
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt: clonePrompt(input.adapter.state.current()),
|
||||
prompt,
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
@@ -150,6 +168,17 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
|
||||
input.adapter.state.set([{ type: "text", content: "", start: 0, end: 0 }, ...prompt.filter(isAttachment)], 0)
|
||||
input.adapter.state.mode.set("normal")
|
||||
input.setMode("normal")
|
||||
input.closePopover()
|
||||
}
|
||||
|
||||
function submissionText(prompt: Prompt) {
|
||||
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
}
|
||||
|
||||
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
return {
|
||||
id: value.id,
|
||||
@@ -193,9 +222,9 @@ function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
context: ComposerSubmission["context"],
|
||||
text: string,
|
||||
alternate: boolean,
|
||||
): ComposerSubmission | undefined {
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const mode = input.mode()
|
||||
if (mode === "shell" && !text.trim()) return
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
@@ -298,14 +327,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
}
|
||||
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
const parsed = parseSlashCommand(text)
|
||||
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
|
||||
return { command: parsed.name, arguments: parsed.input }
|
||||
}
|
||||
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
@@ -390,7 +416,10 @@ async function sendPrompt(
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
const images = await Promise.all(
|
||||
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
|
||||
value.images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
|
||||
@@ -47,7 +47,7 @@ export function HomeCommandPalette(props: {
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
void item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
|
||||
@@ -65,14 +65,14 @@
|
||||
}
|
||||
|
||||
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
|
||||
Move only as far as needed; wider panels already have enough space in the margin. */
|
||||
@container session-chat (min-width: 1320px) {
|
||||
When they overlap, center the conversation in the space that remains beside the summary. */
|
||||
@container session-chat (min-width: 1320px) and (width < 1640px) {
|
||||
[data-slot="session-chat-panel"][data-summary-open="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
--session-summary-translate: min(0px, calc(50cqi - 820px));
|
||||
--session-summary-translate: -160px;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: max(0px, calc(820px - 50cqi));
|
||||
--session-summary-translate: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,8 @@ export const dict = {
|
||||
"command.session.compact.description": "Summarize the session to reduce context size",
|
||||
"command.session.fork": "Fork from message",
|
||||
"command.session.fork.description": "Create a new session from a previous message",
|
||||
"command.session.btw": "Ask a side question",
|
||||
"command.session.btw.description": "Get a one-shot answer without adding to the conversation",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
"command.session.import": "Import session",
|
||||
@@ -440,6 +442,19 @@ export const dict = {
|
||||
"No camera is available to this browser. Enter your connection details manually.",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"command.server.pair": "Pair device",
|
||||
"settings.pairing.title": "Pairing",
|
||||
"settings.pairing.connection": "Local Network",
|
||||
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
|
||||
"pair.local.open": "Show details",
|
||||
"pair.screenActive.title": "Keep screen active",
|
||||
"pair.screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.",
|
||||
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
|
||||
"pair.description": "Connect another device to this machine's OpenCode server.",
|
||||
"pair.qr": "Pairing QR code",
|
||||
"pair.copy": "Copy details",
|
||||
"pair.copy.error": "Could not copy pairing details. Try again.",
|
||||
"pair.error": "Could not update pairing details. Try again.",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
@@ -625,6 +640,16 @@ export const dict = {
|
||||
"toast.file.loadFailed.title": "Failed to load file",
|
||||
"file.error.notFound": "File not found: {{name}}",
|
||||
"toast.file.listFailed.title": "Failed to list files",
|
||||
"file.view.preview": "Preview",
|
||||
"file.view.source": "Source",
|
||||
"file.view.openInBrowser": "Open in browser",
|
||||
"file.view.binary": "Binary file · {{size}}",
|
||||
"file.view.table.rows.one": "{{count}} row",
|
||||
"file.view.table.rows.other": "{{count}} rows",
|
||||
"file.view.table.columns.one": "{{count}} column",
|
||||
"file.view.table.columns.other": "{{count}} columns",
|
||||
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
|
||||
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
|
||||
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
|
||||
@@ -746,6 +771,7 @@ export const dict = {
|
||||
"session.tab.browser": "Browser",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.btw": "/btw",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
@@ -906,6 +932,10 @@ export const dict = {
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.empty.title": "Enter URL",
|
||||
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
|
||||
"session.browser.failed.title": "URL can't be reached",
|
||||
"session.browser.failed.description": "Check the URL and your connection, then try again.",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
@@ -919,6 +949,10 @@ export const dict = {
|
||||
"common.dismiss": "Dismiss",
|
||||
"common.moreCountSuffix": " (+{{count}} more)",
|
||||
"common.requestFailed": "Request failed",
|
||||
"session.btw.questionRequired": "Add a question after /btw",
|
||||
"session.btw.error": "Couldn’t answer that question",
|
||||
"session.btw.retry": "Retry",
|
||||
"session.btw.copy": "Copy answer",
|
||||
"common.moreOptions": "More options",
|
||||
"common.learnMore": "Learn more",
|
||||
"common.rename": "Rename",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "preview"; path: string }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
|
||||
@@ -22,6 +22,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type PairingInfo = {
|
||||
readonly urls: readonly string[]
|
||||
readonly username: "opencode"
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
error: string
|
||||
url: string
|
||||
@@ -101,6 +107,10 @@ type PlatformBase = {
|
||||
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||
|
||||
/** Prevent the local display from sleeping while the desktop app is running. */
|
||||
getKeepScreenActive?(): Promise<boolean>
|
||||
setKeepScreenActive?(enabled: boolean): Promise<void>
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
@@ -124,6 +134,11 @@ type PlatformBase = {
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
|
||||
/** Pair another device with the local desktop server. */
|
||||
pair?: {
|
||||
info(): Promise<PairingInfo>
|
||||
}
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -33,6 +33,8 @@ export type FileContent = {
|
||||
}
|
||||
encoding?: "base64"
|
||||
mimeType?: string
|
||||
/** On-disk size when the bytes themselves are not retained. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type Path = {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
const info = { username: "opencode" as const, password: "a+b & café" }
|
||||
const origin = "https://opencode.example.com:49709"
|
||||
const url = new URL(pairingUrl(info, origin))
|
||||
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(url.search).toBe("")
|
||||
expect(url.hash).not.toBe("")
|
||||
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
|
||||
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps accepting query pairing data", () => {
|
||||
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
|
||||
urls: info.urls,
|
||||
password: info.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts CLI base64url fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid query data", () => {
|
||||
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingUrl("?other=value")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts legacy JSON fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
|
||||
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an invalid fragment", () => {
|
||||
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
@@ -19,10 +20,35 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string) {
|
||||
export function decodePairingCode(value: string, origin?: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
const urls = [
|
||||
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
|
||||
]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
|
||||
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
|
||||
}
|
||||
|
||||
export function decodePairingUrl(value: string, origin?: string) {
|
||||
if (value.startsWith("?")) {
|
||||
const data = new URLSearchParams(value).get("data")
|
||||
return data === null ? undefined : decodePairingCode(data, origin)
|
||||
}
|
||||
const encoded = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!encoded) return
|
||||
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
|
||||
if (legacy.startsWith("{")) return decodePairingCode(legacy)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
|
||||
const binary = atob(
|
||||
encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
|
||||
}
|
||||
|
||||
@@ -9,19 +9,28 @@ import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen() {
|
||||
export function ConnectServerScreen(
|
||||
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const [state, setState] = createStore({
|
||||
url: props.pairing?.urls[0] ?? "",
|
||||
password: props.pairing?.password ?? "",
|
||||
urls: props.pairing?.urls ?? ([] as string[]),
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
@@ -42,6 +51,7 @@ export function ConnectServerScreen() {
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
props.onConnect?.()
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import type { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
|
||||
import { sessionBrowserTab } from "@/shell/state/session-tabs"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
|
||||
|
||||
@@ -35,13 +36,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const route = useCurrentRoute()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const preview = new Map<string, Set<(path: string) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
@@ -87,11 +89,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
// Focus requests write to the owning session's layout even while another shell tab is routed,
|
||||
// so the Review pane and browser tab are already selected when the user returns to it.
|
||||
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
|
||||
const connection = createBrowserConnection({
|
||||
pane,
|
||||
// Resolve the current port at every wake, including after sidecar replacement.
|
||||
@@ -100,7 +105,15 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
sessionID,
|
||||
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
|
||||
}),
|
||||
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
|
||||
focus: (tabID) => {
|
||||
const tab = sessionBrowserTab(tabID)
|
||||
batch(() => {
|
||||
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
|
||||
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
|
||||
tabs.layout.setActive(tab)
|
||||
})
|
||||
},
|
||||
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
|
||||
change: (state) => {
|
||||
if (state.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
@@ -138,17 +151,18 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.forEach((dispose) => dispose())
|
||||
connection.dispose()
|
||||
tabs.dispose()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
/** Agent requests to show a file in this session's Review pane. */
|
||||
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
const listeners = preview.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
preview.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
if (!listeners.size) preview.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
|
||||
@@ -28,10 +28,12 @@ function fixture() {
|
||||
commands: Browser.Action[]
|
||||
}[] = []
|
||||
const endpoint = { url: "http://localhost:4096" }
|
||||
const previews: string[] = []
|
||||
const connection = createBrowserConnection({
|
||||
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
|
||||
change: (state) => states.push(state),
|
||||
focus: () => {},
|
||||
preview: (path) => previews.push(path),
|
||||
pane: {
|
||||
register(target, emit) {
|
||||
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
|
||||
@@ -50,9 +52,21 @@ function fixture() {
|
||||
})
|
||||
connection.wake()
|
||||
calls[0].emit({ type: "state", state: browser })
|
||||
return { connection, calls, states, endpoint }
|
||||
return { connection, calls, states, endpoint, previews }
|
||||
}
|
||||
|
||||
test("preview requests reach the session without touching connection state", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
const before = app.states.length
|
||||
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
|
||||
expect(app.previews).toEqual(["docs/report.pdf"])
|
||||
expect(app.states).toHaveLength(before)
|
||||
} finally {
|
||||
app.connection.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createBrowserConnection(input: {
|
||||
target: () => BrowserPaneTarget
|
||||
change: (state: BrowserConnectionState) => void
|
||||
focus: (tabID: Browser.TabID) => void
|
||||
preview: (path: string) => void
|
||||
}) {
|
||||
const state: BrowserConnectionState = { browser: null, suspended: false }
|
||||
let disposed = false
|
||||
@@ -33,6 +34,7 @@ export function createBrowserConnection(input: {
|
||||
(event) => {
|
||||
if (disposed || state.registration !== registration) return
|
||||
if (event.type === "focus") return input.focus(event.tabID)
|
||||
if (event.type === "preview") return input.preview(event.path)
|
||||
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
|
||||
blocked = true
|
||||
registration.close()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -33,13 +33,6 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
@@ -66,8 +59,7 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
|
||||
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const failed = () => !!state()?.loadError
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
submitted: false,
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !state(),
|
||||
disabled: !props.visible || !address(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
|
||||
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
|
||||
createEffect(
|
||||
on(
|
||||
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
|
||||
() => setStore("navigating", false),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.browser.error(),
|
||||
(error) => {
|
||||
if (error && !store.editing) setStore("address", address())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(registration, (current) => {
|
||||
// Session routes can change before this pane unmounts. Hide the registration
|
||||
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
empty,
|
||||
failed,
|
||||
registration,
|
||||
],
|
||||
() => {
|
||||
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
disabled={!state()?.loading && !address()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
const url = store.address.trim()
|
||||
if (!tab) return
|
||||
if (url || failed()) {
|
||||
setStore({ submitted: true, address: url, navigating: true })
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
|
||||
}
|
||||
event.currentTarget.querySelector("input")?.blur()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onFocus={(event) => {
|
||||
setStore("editing", true)
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
onBlur={() =>
|
||||
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
|
||||
}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<Show when={props.browser.error() && !failed()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
dir="auto"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
|
||||
>
|
||||
<Icon name="globe" size="large" class="mb-2 shrink-0" />
|
||||
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
|
||||
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
|
||||
</div>
|
||||
<div class="text-13-regular leading-[var(--line-height-base)]">
|
||||
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.browser.suspended()}>
|
||||
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
|
||||
{language.t("session.browser.suspended")}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SESSION_BTW_TAB } from "@/session/helpers"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
const instructions = [
|
||||
"The user is asking a quick side question about the conversation so far.",
|
||||
"Answer directly and concisely in markdown from what you already know.",
|
||||
"Do not call any tools and do not take any actions.",
|
||||
].join(" ")
|
||||
|
||||
const empty = {
|
||||
question: "",
|
||||
answer: "",
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
|
||||
export function createSessionBtw(session: SessionModel) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const server = useServerSDK()
|
||||
const [states, setStates] = createStore<Record<string, typeof empty>>({})
|
||||
const requests = new Map<string, number>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const state = () => states[session.identity.sessionKey()] ?? empty
|
||||
|
||||
createEffect(() => {
|
||||
const key = session.identity.sessionKey()
|
||||
onCleanup(() => {
|
||||
const controller = controllers.get(key)
|
||||
if (!controller) return
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
if (states[key]?.pending) setStates(key, { pending: false, error: true })
|
||||
})
|
||||
})
|
||||
|
||||
const open = () => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
if (tabs.active() !== SESSION_BTW_TAB) tabs.open(SESSION_BTW_TAB)
|
||||
}
|
||||
const ask = (value?: string) => {
|
||||
const question = value?.trim()
|
||||
if (!question) {
|
||||
showToast({ title: language.t("session.btw.questionRequired") })
|
||||
return
|
||||
}
|
||||
open()
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
|
||||
const key = session.identity.sessionKey()
|
||||
const request = (requests.get(key) ?? 0) + 1
|
||||
requests.set(key, request)
|
||||
controllers.get(key)?.abort()
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const owner = session.ownership.capture()
|
||||
setStates(key, { question, answer: "", error: false, pending: true })
|
||||
return server.api.session
|
||||
.generate(
|
||||
{
|
||||
sessionID,
|
||||
prompt: [instructions, question].join("\n\n"),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
owner.run(() => {
|
||||
if (requests.get(key) !== request) return
|
||||
setStates(key, { answer: result.text.trim(), pending: false })
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
owner.run(() => {
|
||||
if (controller.signal.aborted || requests.get(key) !== request) return
|
||||
setStates(key, { error: true, pending: false })
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (controllers.get(key) === controller) controllers.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
command.register("session.btw", () => [
|
||||
{
|
||||
id: "session.btw",
|
||||
title: language.t("command.session.btw"),
|
||||
description: language.t("command.session.btw.description"),
|
||||
category: language.t("command.category.session"),
|
||||
slash: "btw",
|
||||
slashArguments: true,
|
||||
hidden: true,
|
||||
disabled: !session.isDesktop(),
|
||||
onSelect: (_source, input) => ask(input),
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
answer: () => state().answer,
|
||||
error: () => state().error,
|
||||
pending: () => state().pending,
|
||||
question: () => state().question,
|
||||
retry: () => ask(state().question),
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionBtwModel = ReturnType<typeof createSessionBtw>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionBtwModel } from "./model"
|
||||
|
||||
export function SessionBtwPanel(props: { btw: SessionBtwModel }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
props.btw.answer()
|
||||
setCopied(false)
|
||||
})
|
||||
|
||||
const copy = () => {
|
||||
const answer = props.btw.answer()
|
||||
if (!answer) return
|
||||
void (platform.writeClipboardText?.(answer) ?? navigator.clipboard.writeText(answer)).then(
|
||||
() => setCopied(true),
|
||||
() => showToast({ title: language.t("common.requestFailed") }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
|
||||
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question()}</div>
|
||||
<Show when={props.btw.answer()}>
|
||||
<Tooltip value={copied() ? language.t("common.copied") : language.t("session.btw.copy")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
|
||||
aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")}
|
||||
onClick={copy}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<Switch>
|
||||
<Match when={props.btw.pending()}>
|
||||
<div
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
|
||||
>
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.btw.error()}>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
|
||||
<div class="text-13-regular text-text-weak">{language.t("session.btw.error")}</div>
|
||||
<Button size="small" variant="outline" onClick={props.btw.retry}>
|
||||
{language.t("session.btw.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.btw.answer()}>
|
||||
<ScrollView class="absolute inset-0">
|
||||
<div class="px-5 py-4 pb-8">
|
||||
<Markdown text={props.btw.answer()} class="text-14-regular" />
|
||||
</div>
|
||||
</ScrollView>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"] {
|
||||
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
|
||||
background-color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-checker] {
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
}
|
||||
|
||||
[data-slot="artifact-media"] {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 0 0.5px var(--v2-border-border-base),
|
||||
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th,
|
||||
[data-slot="artifact-table"] td {
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--v2-border-border-muted);
|
||||
border-inline-end: 1px solid var(--v2-border-border-muted);
|
||||
white-space: pre;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] td[data-index] {
|
||||
color: var(--v2-text-text-faint);
|
||||
text-align: end;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
artifactKind,
|
||||
blobUrlFromContent,
|
||||
contentBytes,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
type ArtifactKind,
|
||||
} from "@/workspaces/files/artifact"
|
||||
import { useArtifactOpener } from "@/session/files/open-artifact"
|
||||
import "./artifact-view.css"
|
||||
|
||||
type ArtifactMode = "preview" | "source"
|
||||
|
||||
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
|
||||
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
|
||||
|
||||
type MediaProps = {
|
||||
path: string
|
||||
content: FileContent
|
||||
onInfo: (info: ArtifactInfo) => void
|
||||
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
|
||||
onError: () => void
|
||||
}
|
||||
|
||||
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
|
||||
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
|
||||
|
||||
/**
|
||||
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
|
||||
* previewable text kinds can switch to `source`, which the host supplies (its code view).
|
||||
*/
|
||||
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
mode: "preview" as ArtifactMode,
|
||||
info: {} as ArtifactInfo,
|
||||
// Media the browser could not decode falls back to the binary placeholder.
|
||||
undecodable: false,
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.content,
|
||||
() => setState({ mode: "preview", info: {}, undecodable: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kind = createMemo<ArtifactKind | "binary">(() => {
|
||||
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
|
||||
if (state.undecodable) return "binary"
|
||||
return artifactKind(props.path)
|
||||
})
|
||||
const previewable = createMemo(() => {
|
||||
const value = kind()
|
||||
return value !== "binary" && previewableKinds.has(value)
|
||||
})
|
||||
const meta = createMemo(() => {
|
||||
const info = state.info
|
||||
return [
|
||||
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
|
||||
info.duration ? formatDuration(info.duration) : undefined,
|
||||
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
|
||||
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
|
||||
formatBytes(language.intl(), contentBytes(props.content)),
|
||||
].filter((item): item is string => !!item)
|
||||
})
|
||||
|
||||
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
|
||||
const rendered = () => (
|
||||
<ScrollView class="min-h-0 flex-1">
|
||||
<Show
|
||||
when={kind() === "markdown"}
|
||||
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
|
||||
>
|
||||
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
|
||||
</Show>
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArtifactToolbar
|
||||
mode={state.mode}
|
||||
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
|
||||
meta={meta()}
|
||||
actions={
|
||||
<Show when={kind() === "html"}>
|
||||
<OpenInBrowserButton path={props.path} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "svg"}>
|
||||
<ArtifactImage path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "video"}>
|
||||
<ArtifactVideo path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "audio"}>
|
||||
<ArtifactAudio path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "pdf" || kind() === "html"}>
|
||||
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
|
||||
</Match>
|
||||
<Match when={kind() === "font"}>
|
||||
<ArtifactFont path={props.path} content={props.content} />
|
||||
</Match>
|
||||
<Match when={kind() === "table"}>
|
||||
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
|
||||
</Match>
|
||||
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
|
||||
<Match when={kind() === "binary"}>
|
||||
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(locale: string, bytes: number) {
|
||||
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
|
||||
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
|
||||
const value = bytes / 1000 ** index
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit: units[index],
|
||||
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
|
||||
unitDisplay: index === 0 ? "long" : "short",
|
||||
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const total = Math.round(seconds)
|
||||
const minutes = Math.floor(total / 60)
|
||||
return `${minutes}:${String(total % 60).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function ArtifactToolbar(props: {
|
||||
mode?: ArtifactMode
|
||||
onModeChange?: (mode: ArtifactMode) => void
|
||||
meta: string[]
|
||||
actions?: JSX.Element
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
|
||||
<Show when={props.onModeChange}>
|
||||
<SegmentedControl
|
||||
value={props.mode ?? "preview"}
|
||||
onChange={(value) => {
|
||||
if (value === "preview" || value === "source") props.onModeChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
|
||||
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
</Show>
|
||||
<div class="ms-auto flex min-w-0 items-center gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
|
||||
<For each={props.meta}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span aria-hidden class="text-text-faint">
|
||||
·
|
||||
</span>
|
||||
</Show>
|
||||
<span class="truncate tabular-nums">{item}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenInBrowserButton(props: { path: string }) {
|
||||
const language = useLanguage()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<Show when={artifacts.canOpenInBrowser(props.path)}>
|
||||
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
|
||||
{language.t("file.view.openInBrowser")}
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createBlobUrl(content: () => FileContent) {
|
||||
return createMemo(() => {
|
||||
const value = blobUrlFromContent(content())
|
||||
onCleanup(() => URL.revokeObjectURL(value))
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
|
||||
function ArtifactImage(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
|
||||
let stage: HTMLDivElement | undefined
|
||||
const measure = () => {
|
||||
if (!stage) return
|
||||
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
|
||||
}
|
||||
createResizeObserver(
|
||||
() => stage,
|
||||
() => measure(),
|
||||
)
|
||||
createEffect(() => {
|
||||
url()
|
||||
setState({ zoom: "fit", overflow: false })
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={stage}
|
||||
data-slot="artifact-stage"
|
||||
data-checker
|
||||
data-zoom={state.zoom}
|
||||
data-overflow={state.overflow || undefined}
|
||||
class="relative min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
|
||||
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
data-slot="artifact-media"
|
||||
src={url()}
|
||||
alt={getFilename(props.path)}
|
||||
draggable={false}
|
||||
onError={() => props.onError()}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
setState({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
measure()
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!state.overflow && state.zoom === "fit") return
|
||||
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactVideo(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<video
|
||||
data-slot="artifact-media"
|
||||
class="w-full bg-black"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
onError={() => props.onError()}
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => {
|
||||
const video = event.currentTarget
|
||||
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactAudio(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
|
||||
</div>
|
||||
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<audio
|
||||
class="w-full"
|
||||
onError={() => props.onError()}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
|
||||
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
|
||||
return (
|
||||
<iframe
|
||||
class="block h-full w-full flex-1 border-0 bg-white"
|
||||
title={getFilename(props.path)}
|
||||
src={src()}
|
||||
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
|
||||
// opaque origin: no app storage, cookies, or credentialed requests reach it.
|
||||
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
|
||||
const parent = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
|
||||
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
|
||||
// Absolute references bypass the file's directory; relative ones resolve against it.
|
||||
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
|
||||
return (
|
||||
<MarkdownProvider
|
||||
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
|
||||
openLocalFile={(href) => artifacts.open(href, dir())}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
|
||||
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-4xl px-8 py-6">
|
||||
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
|
||||
const language = useLanguage()
|
||||
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
|
||||
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
|
||||
// Pad the header to the widest row so no data column is dropped.
|
||||
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
|
||||
const body = () => parsed().rows.slice(1)
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-index />
|
||||
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={body()}>
|
||||
{(row, index) => (
|
||||
<tr>
|
||||
<td data-index>{index() + 1}</td>
|
||||
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={parsed().total > parsed().rows.length}>
|
||||
<div class="px-4 py-3 text-12-regular text-text-weak">
|
||||
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const specimenSizes = [12, 16, 24, 40, 64]
|
||||
|
||||
function ArtifactFont(props: { path: string; content: FileContent }) {
|
||||
const language = useLanguage()
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
|
||||
createEffect(() => {
|
||||
const face = new FontFace(family(), `url(${url()})`)
|
||||
document.fonts.add(face)
|
||||
void face.load().catch(() => undefined)
|
||||
onCleanup(() => document.fonts.delete(face))
|
||||
})
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
|
||||
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
|
||||
{getFilename(props.path).replace(/\.[^.]+$/, "")}
|
||||
</div>
|
||||
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
<br />
|
||||
abcdefghijklmnopqrstuvwxyz
|
||||
<br />
|
||||
0123456789 !?&@#%(){}[]
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
|
||||
<For each={specimenSizes}>
|
||||
{(size) => (
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
|
||||
style={{ "font-family": "var(--font-family-mono)" }}
|
||||
>
|
||||
{size}
|
||||
</span>
|
||||
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
|
||||
{language.t("file.view.fontSample")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactBinary(props: { path: string; size: string }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
|
||||
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -11,8 +11,9 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { artifactKind } from "@/workspaces/files/artifact"
|
||||
import { ArtifactView } from "@/session/files/artifact-view"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -205,6 +206,11 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
|
||||
const artifact = createMemo(() => {
|
||||
const content = state()?.content
|
||||
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
|
||||
})
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
@@ -433,39 +439,42 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
}}
|
||||
search={search}
|
||||
class="select-text"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
// Media and previews have their own viewers below; the code view only ever shows text.
|
||||
media={{ mode: "off" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
|
||||
const codeView = (source: string) => (
|
||||
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
{renderFile(source)}
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
const content = () => (
|
||||
<div class="mt-3 relative h-full min-h-0">
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => (
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</ScrollView>
|
||||
<div class="mt-3 relative h-full min-h-0 flex flex-col">
|
||||
<Switch>
|
||||
<Match when={state()?.loaded ? state()?.content : undefined}>
|
||||
{(value) => (
|
||||
<Show when={artifact()} fallback={codeView(value().content)}>
|
||||
<ArtifactView
|
||||
path={path() ?? ""}
|
||||
content={value()}
|
||||
cacheKey={cacheKey()}
|
||||
source={codeView(value().content)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createEffect, onCleanup, type ParentProps } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { useBrowserAttachments } from "@/session/browser/attachments"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createOpenSessionFileTab } from "@/session/helpers"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
|
||||
export function ArtifactMarkdownProvider(props: ParentProps) {
|
||||
const markdown = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
|
||||
{props.children}
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
|
||||
* a browser tab for HTML when the desktop can load the file directly.
|
||||
*/
|
||||
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
|
||||
name: "ArtifactOpener",
|
||||
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
|
||||
const file = useFile()
|
||||
const server = useServer()
|
||||
const location = useWorkspaceLocation()
|
||||
const attachments = useBrowserAttachments()
|
||||
const { tabs, view } = useSessionLayout()
|
||||
|
||||
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
|
||||
/**
|
||||
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
|
||||
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
|
||||
* become absolute too, so a `../../shared/report.pdf` still opens.
|
||||
*/
|
||||
const resolve = (href: string, base?: string) => {
|
||||
// Agents cite locations as path:line or path:line:col; the file is what opens.
|
||||
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
|
||||
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
|
||||
const relative = resolveArtifactPath(base ?? "", value)
|
||||
if (relative !== undefined) return file.normalize(relative)
|
||||
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
|
||||
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
|
||||
return file.normalize(resolveArtifactPath(dir, value) ?? value)
|
||||
}
|
||||
|
||||
const showTab = createOpenSessionFileTab({
|
||||
normalizeTab: (tab) => tab,
|
||||
openTab: (tab) => tabs().open(tab),
|
||||
pathFromTab: file.pathFromTab,
|
||||
loadFile: () => undefined,
|
||||
openReviewPanel: () => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
},
|
||||
setActive: (tab) => tabs().setActive(tab),
|
||||
})
|
||||
|
||||
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
|
||||
const openTab = (path: string) => {
|
||||
void file.load(path).then(() => {
|
||||
if (file.get(path)?.loaded) showTab(file.tab(path))
|
||||
})
|
||||
}
|
||||
|
||||
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
|
||||
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
|
||||
const canOpenInBrowser = (path?: string) =>
|
||||
ServerConnection.builtin(server.conn) &&
|
||||
props.browser.available() &&
|
||||
props.browser.attached() &&
|
||||
(path === undefined || !file.absolute(path))
|
||||
|
||||
const openInBrowser = (path: string) => {
|
||||
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
|
||||
}
|
||||
|
||||
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
|
||||
const open = (href: string, base?: string) => {
|
||||
const path = resolve(href, base)
|
||||
if (!path) return
|
||||
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
|
||||
openTab(path)
|
||||
}
|
||||
|
||||
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
|
||||
createEffect(() => {
|
||||
const sessionID = props.session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
|
||||
})
|
||||
|
||||
return { canOpenInBrowser, openInBrowser, open }
|
||||
},
|
||||
})
|
||||
@@ -173,7 +173,9 @@ export function SessionFileBrowserTab(props: {
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
|
||||
{language.t("command.file.open")}
|
||||
</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
SESSION_BTW_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
@@ -74,6 +75,7 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
btwPanel: () => JSX.Element
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -227,7 +229,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
return active === SESSION_OPEN_FILE_TAB || active === activeFileTab()
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
|
||||
@@ -385,6 +387,14 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={tab === SESSION_BTW_TAB}>
|
||||
<SortableTab tab={tab} index={tabs().all().indexOf(tab)} onTabClose={tabs().close}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="bubble-5" size="small" />
|
||||
<span>{language.t("session.tab.btw")}</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
</Match>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
@@ -583,6 +593,12 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === SESSION_BTW_TAB}>
|
||||
<Tabs.Content value={SESSION_BTW_TAB} class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
{props.btwPanel()}
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
@@ -69,9 +71,19 @@ export function SortableTab(props: {
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onTabClose(props.tab)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BTW_TAB,
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
@@ -236,6 +237,24 @@ describe("createSessionTabs", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes the BTW tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BTW_TAB, all: () => [SESSION_BTW_TAB] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BTW_TAB])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BTW_TAB)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BTW_TAB)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
|
||||
@@ -2,10 +2,11 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isSessionBrowserTab, SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_BTW_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
@@ -63,13 +64,17 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
() =>
|
||||
panelTabs().filter(
|
||||
(tab) => tab !== SESSION_OPEN_FILE_TAB && tab !== SESSION_BTW_TAB && !isSessionBrowserTab(tab),
|
||||
),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_BTW_TAB) return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
@@ -89,6 +94,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_BTW_TAB) return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
|
||||
@@ -137,6 +137,7 @@ export function useSessionModel() {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
sessionKey: layout.sessionKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -155,6 +155,9 @@ export function createSessionReview(input: {
|
||||
const count = () => diffs().length
|
||||
const hasChanges = () => count() > 0
|
||||
const ready = () => {
|
||||
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
|
||||
const project = input.session.project()
|
||||
if (project && !project.vcs) return true
|
||||
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionBtwModel } from "../btw/model"
|
||||
import { SessionBtwPanel } from "../btw/panel"
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
@@ -127,6 +129,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
btw: SessionBtwModel
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
@@ -153,6 +156,7 @@ export function SessionDesktopReview(props: {
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
btwPanel={() => <SessionBtwPanel btw={props.btw} />}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
@@ -249,7 +253,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
|
||||
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const noGit = () => props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
|
||||
@@ -282,7 +286,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
|
||||
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const noGit = () => props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
|
||||
@@ -38,6 +38,8 @@ import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
|
||||
import { createSessionBtw } from "./btw/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -50,14 +52,27 @@ const SessionSummaryPanel = lazy(async () => {
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
// The timeline cache captures its owner when created, so link handling must be provided above it.
|
||||
const browser = createSessionBrowser(props.session)
|
||||
return (
|
||||
<ArtifactOpenerProvider session={props.session} browser={browser}>
|
||||
<ArtifactMarkdownProvider>
|
||||
<SessionScreenContent session={props.session} browser={browser} />
|
||||
</ArtifactMarkdownProvider>
|
||||
</ArtifactOpenerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
|
||||
const session = props.session
|
||||
const browser = props.browser
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const btw = createSessionBtw(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
@@ -438,7 +453,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview
|
||||
review={review}
|
||||
browser={browser}
|
||||
btw={btw}
|
||||
present={store.sideReviewPresent}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const pageIcons = {
|
||||
appearance: "appearance",
|
||||
notifications: "notifications",
|
||||
shortcuts: "keyboard",
|
||||
pairing: "server",
|
||||
projects: "folder",
|
||||
workspaces: "outline-worktree",
|
||||
providers: "providers",
|
||||
@@ -22,6 +23,7 @@ export const pageLabels = {
|
||||
appearance: "settings.general.section.appearance",
|
||||
notifications: "settings.tab.notifications",
|
||||
shortcuts: "settings.shortcuts.title",
|
||||
pairing: "settings.pairing.title",
|
||||
projects: "settings.tab.projects",
|
||||
workspaces: "settings.tab.workspaces",
|
||||
providers: "settings.providers.title",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { renderSVG } from "uqr"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
|
||||
import { pairingUrl } from "@/servers/connect/pairing"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
|
||||
export function SettingsPairing() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const pair = platform.pair
|
||||
if (!pair) return null
|
||||
const local = useQuery(() => ({
|
||||
queryKey: ["pairing", "local"],
|
||||
queryFn: pair.info,
|
||||
}))
|
||||
// Reading pending query data would suspend the entire settings surface.
|
||||
const localInfo = () => (local.isSuccess ? local.data : undefined)
|
||||
const localHost = createMemo(() =>
|
||||
localInfo()?.urls.find((value) => {
|
||||
const host = new URL(value).hostname
|
||||
return (
|
||||
host !== "localhost" &&
|
||||
!host.endsWith(".localhost") &&
|
||||
!host.startsWith("127.") &&
|
||||
host !== "[::1]" &&
|
||||
host !== "0.0.0.0" &&
|
||||
host !== "[::]"
|
||||
)
|
||||
}),
|
||||
)
|
||||
const screenActive = useQuery(() => ({
|
||||
queryKey: ["pairing", "screen-active"],
|
||||
queryFn: () => platform.getKeepScreenActive!(),
|
||||
enabled: !!platform.getKeepScreenActive,
|
||||
}))
|
||||
const screenActivity = useMutation(() => ({
|
||||
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
|
||||
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.pairing.connection")}
|
||||
description={language.t("pair.local.description")}
|
||||
>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localHost()}
|
||||
onClick={() =>
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("settings.pairing.connection")}
|
||||
info={localInfo()}
|
||||
host={localHost()!}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("pair.local.open")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
|
||||
<div data-action="settings-keep-screen-active">
|
||||
<SettingsRow
|
||||
title={language.t("pair.screenActive.title")}
|
||||
description={language.t("pair.screenActive.description")}
|
||||
>
|
||||
<Switch
|
||||
hideLabel
|
||||
checked={screenActive.isSuccess && screenActive.data}
|
||||
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
|
||||
onChange={(enabled) => screenActivity.mutate(enabled)}
|
||||
>
|
||||
{language.t("pair.screenActive.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
<Show when={screenActive.error || screenActivity.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.screenActive.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={local.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const url = createMemo(() => {
|
||||
if (!props.info) return
|
||||
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
|
||||
})
|
||||
const origin = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return new URL(value).origin
|
||||
})
|
||||
const copy = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
|
||||
},
|
||||
}))
|
||||
createEffect(() => {
|
||||
if (!copy.isSuccess) return
|
||||
const timeout = setTimeout(() => copy.reset(), 2000)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
const qr = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
|
||||
<Show when={props.info}>
|
||||
<div
|
||||
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
|
||||
role="img"
|
||||
aria-label={language.t("pair.qr")}
|
||||
innerHTML={qr()}
|
||||
/>
|
||||
<div class="flex min-w-0 justify-center pb-2">
|
||||
<Tooltip
|
||||
class="min-w-0 max-w-full"
|
||||
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
|
||||
placement="top"
|
||||
forceOpen={copy.isSuccess ? true : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
|
||||
disabled={copy.isPending}
|
||||
aria-label={language.t("pair.copy")}
|
||||
onClick={() => copy.mutate()}
|
||||
>
|
||||
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
|
||||
<bdi dir="ltr" class="min-w-0 break-all text-start">
|
||||
{origin()}
|
||||
</bdi>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={copy.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.copy.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,20 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
{ tab: "appearance", label: "settings.general.section.appearance" },
|
||||
{ tab: "notifications", label: "settings.tab.notifications" },
|
||||
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "settings.pairing.title",
|
||||
keywords: "pair device qr local",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "pair.screenActive.title",
|
||||
description: "pair.screenActive.description",
|
||||
target: "settings-keep-screen-active",
|
||||
keywords: "display sleep awake local",
|
||||
available: "desktop",
|
||||
},
|
||||
{ tab: "experimental", label: "settings.tab.experimental" },
|
||||
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
|
||||
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
|
||||
|
||||
@@ -1102,10 +1102,6 @@
|
||||
padding-block: 20px;
|
||||
}
|
||||
|
||||
.settings-server-connection .settings-servers-lead {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-servers-row:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
@@ -18,6 +19,7 @@ import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsExperimental } from "./experimental/experimental"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsPairing } from "./pairing/pairing"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
import { SettingsModels } from "./models/models"
|
||||
import { SettingsServerGeneral } from "./servers/servers"
|
||||
@@ -41,6 +43,7 @@ const rootClientTabs = [
|
||||
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
|
||||
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
|
||||
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
|
||||
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
|
||||
] as const
|
||||
|
||||
const serverTabs = [
|
||||
@@ -187,6 +190,7 @@ function RootSettings() {
|
||||
const tabs = useTabs()
|
||||
const servers = useServerCollectionController()
|
||||
const inventory = useSettingsServers()
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const list = servers.collection.items
|
||||
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
|
||||
@@ -216,7 +220,11 @@ function RootSettings() {
|
||||
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
|
||||
))
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
|
||||
{
|
||||
items: rootClientTabs
|
||||
.filter((item) => item.value !== "pairing" || !!platform.pair)
|
||||
.map((item) => ({ ...item, label: language.t(item.label) })),
|
||||
},
|
||||
...(multiple()
|
||||
? [
|
||||
{
|
||||
@@ -282,6 +290,9 @@ function RootSettings() {
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pairing" class="settings-panel">
|
||||
<SettingsPairing />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SettingsRootTab =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "shortcuts"
|
||||
| "pairing"
|
||||
| "projects"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
@@ -44,6 +45,7 @@ const rootTabs: Record<SettingsRootTab, true> = {
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
pairing: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
|
||||
@@ -88,11 +88,12 @@ export interface CommandOption {
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
slashArguments?: boolean
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
when?: (event: KeyboardEvent) => boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
@@ -389,9 +390,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: CommandSource) => {
|
||||
const run = (id: string, source?: CommandSource, input?: string) => {
|
||||
const option = optionMap().get(id)
|
||||
option?.onSelect?.(source)
|
||||
return option?.onSelect?.(source, input)
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
@@ -420,7 +421,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
option.onSelect?.("keybind")
|
||||
void option.onSelect?.("keybind")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -454,8 +455,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
|
||||
return {
|
||||
register,
|
||||
trigger(id: string, source?: CommandSource) {
|
||||
run(id, source)
|
||||
trigger(id: string, source?: CommandSource, input?: string) {
|
||||
return run(id, source, input)
|
||||
},
|
||||
keybind(id: string) {
|
||||
const config = keybindConfig(id)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCommand, type CommandOption } from "./command"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DialogSsh } from "@/servers/ssh/dialog"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
|
||||
export function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
@@ -40,3 +41,25 @@ export function DesktopCommands() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function DesktopPairingCommand() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettingsSurface()
|
||||
|
||||
command.register("desktop-pairing", () =>
|
||||
platform.platform === "desktop" && platform.pair
|
||||
? [
|
||||
{
|
||||
id: "server.pair",
|
||||
title: language.t("command.server.pair"),
|
||||
category: language.t("command.category.server"),
|
||||
onSelect: () => settings.open("pairing"),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
void item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Route, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -10,6 +10,8 @@ import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
import { decodePairingUrl } from "@/servers/connect/pairing"
|
||||
import { DesktopPairingCommand } from "@/shell/commands/desktop"
|
||||
|
||||
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
@@ -26,6 +28,7 @@ export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -33,29 +36,48 @@ export function preloadRoute(url: string) {
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
<>
|
||||
<Route path="/connect" component={ConnectRoute} />
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectRoute() {
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
|
||||
onMount(() => {
|
||||
if (!pairing) return
|
||||
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
return (
|
||||
<Show when={!pairing}>
|
||||
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,6 +101,7 @@ function AppLayout(props: ParentProps) {
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<DesktopPairingCommand />
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
|
||||
@@ -10,6 +10,10 @@ test("settings has its own layout route", () => {
|
||||
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
|
||||
})
|
||||
|
||||
test("connect has its own layout route", () => {
|
||||
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
@@ -96,7 +100,7 @@ describe("layout persistence", () => {
|
||||
test("keeps scoped state and salvages valid tab entries", () => {
|
||||
const key = "local\u0000L3Byb2plY3Q/session"
|
||||
const value = decode({
|
||||
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
|
||||
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b", "btw"], active: "btw" } },
|
||||
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
|
||||
})
|
||||
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode/ui/project-avatar"
|
||||
import { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./session-tabs"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, SESSION_BTW_TAB, type SessionTabs } from "./session-tabs"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
@@ -67,6 +67,7 @@ export type TabPanes = {
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "settings" }
|
||||
| { type: "connect" }
|
||||
| { type: "draft"; draftID: string }
|
||||
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||
|
||||
@@ -97,8 +98,9 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
|
||||
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
const path = sessionPath(key)
|
||||
return {
|
||||
all: normalizeSessionTabList(path, tabs.all),
|
||||
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
all: normalizeSessionTabList(path, tabs.all).filter((tab) => tab !== SESSION_BTW_TAB),
|
||||
active:
|
||||
tabs.active === SESSION_BTW_TAB ? undefined : tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +108,7 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
if (parts[0] === "settings") return { type: "settings" }
|
||||
if (parts[0] === "connect") return { type: "connect" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const SESSION_BTW_TAB = "btw"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] {
|
||||
[data-titlebar-tab],
|
||||
[data-titlebar-tab-action] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
--tab-overlay: transparent;
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
@@ -68,6 +69,14 @@
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
@@ -303,6 +303,7 @@ export function Titlebar(props: {
|
||||
return
|
||||
}
|
||||
case "settings":
|
||||
case "connect":
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
const conn =
|
||||
@@ -351,9 +352,10 @@ export function Titlebar(props: {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
@@ -650,8 +652,9 @@ export function Titlebar(props: {
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
artifactKind,
|
||||
bytesToBase64,
|
||||
contentBytes,
|
||||
fileContentFromBytes,
|
||||
MAX_MEDIA_BYTES,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
} from "./artifact"
|
||||
|
||||
describe("artifactKind", () => {
|
||||
test.each([
|
||||
["shot.PNG", "image"],
|
||||
["logo.svg", "svg"],
|
||||
["song.mp3", "audio"],
|
||||
["demo.mp4", "video"],
|
||||
["clip.webm", "video"],
|
||||
["paper.pdf", "pdf"],
|
||||
["out/index.html", "html"],
|
||||
["README.md", "markdown"],
|
||||
["flow.mmd", "mermaid"],
|
||||
["data.csv", "table"],
|
||||
["data.tsv", "table"],
|
||||
["Inter.woff2", "font"],
|
||||
["src/app.ts", "text"],
|
||||
["Makefile", "text"],
|
||||
[".env", "text"],
|
||||
["archive.tar.gz", "text"],
|
||||
] as const)("classifies %s as %s", (path, kind) => {
|
||||
expect(artifactKind(path)).toBe(kind)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileContentFromBytes", () => {
|
||||
test("keeps media as base64 with a mime type", () => {
|
||||
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
|
||||
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
|
||||
})
|
||||
|
||||
test("decodes text and svg with a mime type", () => {
|
||||
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
|
||||
type: "text",
|
||||
content: "<svg/>",
|
||||
mimeType: "image/svg+xml",
|
||||
})
|
||||
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
|
||||
type: "text",
|
||||
content: "const a = 1",
|
||||
mimeType: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps only the size of media above the cap", () => {
|
||||
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
|
||||
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
|
||||
})
|
||||
|
||||
test("marks unknown binaries without keeping bytes", () => {
|
||||
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
|
||||
})
|
||||
|
||||
test("encodes large buffers in chunks", () => {
|
||||
const bytes = new Uint8Array(70_000).fill(65)
|
||||
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("contentBytes", () => {
|
||||
test("recovers byte counts from base64 and text", () => {
|
||||
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
|
||||
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
|
||||
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDelimited", () => {
|
||||
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
|
||||
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
|
||||
expect(parsed.rows).toEqual([
|
||||
["name", "note"],
|
||||
["Smith, J", 'says "hi"\nand more'],
|
||||
["plain", ""],
|
||||
])
|
||||
expect(parsed.total).toBe(3)
|
||||
expect(parsed.columns).toBe(2)
|
||||
})
|
||||
|
||||
test("counts rows past the limit without keeping them", () => {
|
||||
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
|
||||
expect(parsed.rows).toHaveLength(2)
|
||||
expect(parsed.total).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveArtifactPath", () => {
|
||||
test.each([
|
||||
["docs", "guide.md", "docs/guide.md"],
|
||||
["docs", "./img/a.png", "docs/img/a.png"],
|
||||
["docs/api", "../index.md", "docs/index.md"],
|
||||
["", "src/app.ts", "src/app.ts"],
|
||||
["docs", "sub\\win.md", "docs/sub/win.md"],
|
||||
["", "docs/guide.md", "docs/guide.md"],
|
||||
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
|
||||
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
|
||||
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
|
||||
])("resolves %s + %s", (base, href, expected) => {
|
||||
expect(resolveArtifactPath(base, href)).toBe(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["docs", "../../etc/passwd"],
|
||||
["", "../x"],
|
||||
["docs", "/abs/path"],
|
||||
])("rejects %s + %s", (base, href) => {
|
||||
expect(resolveArtifactPath(base, href)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
|
||||
export type ArtifactKind =
|
||||
| "image"
|
||||
| "svg"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "html"
|
||||
| "markdown"
|
||||
| "mermaid"
|
||||
| "table"
|
||||
| "font"
|
||||
| "text"
|
||||
|
||||
const mimes = new Map([
|
||||
["png", "image/png"],
|
||||
["jpg", "image/jpeg"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
["tif", "image/tiff"],
|
||||
["tiff", "image/tiff"],
|
||||
["heic", "image/heic"],
|
||||
["svg", "image/svg+xml"],
|
||||
["mp3", "audio/mpeg"],
|
||||
["wav", "audio/wav"],
|
||||
["ogg", "audio/ogg"],
|
||||
["oga", "audio/ogg"],
|
||||
["m4a", "audio/mp4"],
|
||||
["aac", "audio/aac"],
|
||||
["flac", "audio/flac"],
|
||||
["opus", "audio/ogg"],
|
||||
["weba", "audio/webm"],
|
||||
["mp4", "video/mp4"],
|
||||
["m4v", "video/mp4"],
|
||||
["webm", "video/webm"],
|
||||
["mov", "video/quicktime"],
|
||||
["ogv", "video/ogg"],
|
||||
["mkv", "video/x-matroska"],
|
||||
["pdf", "application/pdf"],
|
||||
["html", "text/html"],
|
||||
["htm", "text/html"],
|
||||
["md", "text/markdown"],
|
||||
["markdown", "text/markdown"],
|
||||
["mdx", "text/markdown"],
|
||||
["mmd", "text/vnd.mermaid"],
|
||||
["mermaid", "text/vnd.mermaid"],
|
||||
["csv", "text/csv"],
|
||||
["tsv", "text/tab-separated-values"],
|
||||
["ttf", "font/ttf"],
|
||||
["otf", "font/otf"],
|
||||
["woff", "font/woff"],
|
||||
["woff2", "font/woff2"],
|
||||
])
|
||||
|
||||
export function artifactExtension(path: string) {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const index = name.lastIndexOf(".")
|
||||
if (index <= 0) return ""
|
||||
return name.slice(index + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function artifactMime(path: string) {
|
||||
return mimes.get(artifactExtension(path))
|
||||
}
|
||||
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const mime = artifactMime(path)
|
||||
if (!mime) return "text"
|
||||
if (mime === "image/svg+xml") return "svg"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime === "text/html") return "html"
|
||||
if (mime === "text/markdown") return "markdown"
|
||||
if (mime === "text/vnd.mermaid") return "mermaid"
|
||||
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("font/")) return "font"
|
||||
return "video"
|
||||
}
|
||||
|
||||
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
|
||||
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
|
||||
|
||||
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
|
||||
function isBinaryBytes(bytes: Uint8Array) {
|
||||
return bytes.subarray(0, 8192).includes(0)
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
const parts: string[] = []
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
|
||||
}
|
||||
return btoa(parts.join(""))
|
||||
}
|
||||
|
||||
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
|
||||
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
|
||||
const kind = artifactKind(path)
|
||||
const mimeType = artifactMime(path)
|
||||
if (binaryKinds.has(kind)) {
|
||||
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
|
||||
}
|
||||
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
|
||||
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
|
||||
}
|
||||
|
||||
/** Approximate on-disk size of loaded content. */
|
||||
export function contentBytes(content: FileContent) {
|
||||
if (content.size !== undefined) return content.size
|
||||
if (content.encoding === "base64") {
|
||||
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
|
||||
return Math.floor((content.content.length * 3) / 4) - padding
|
||||
}
|
||||
return new TextEncoder().encode(content.content).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
|
||||
* doubled quotes. Rows beyond `limit` are counted but not returned.
|
||||
*/
|
||||
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ""
|
||||
let quoted = false
|
||||
let total = 0
|
||||
const endRow = () => {
|
||||
row.push(field)
|
||||
field = ""
|
||||
const blank = row.length === 1 && row[0] === ""
|
||||
if (!blank) {
|
||||
total++
|
||||
if (rows.length < limit) rows.push(row)
|
||||
}
|
||||
row = []
|
||||
}
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index]!
|
||||
if (quoted) {
|
||||
if (char !== '"') {
|
||||
field += char
|
||||
continue
|
||||
}
|
||||
if (text[index + 1] === '"') {
|
||||
field += '"'
|
||||
index++
|
||||
continue
|
||||
}
|
||||
quoted = false
|
||||
continue
|
||||
}
|
||||
if (char === '"' && field === "") {
|
||||
quoted = true
|
||||
continue
|
||||
}
|
||||
if (char === delimiter) {
|
||||
row.push(field)
|
||||
field = ""
|
||||
continue
|
||||
}
|
||||
if (char === "\r") continue
|
||||
if (char === "\n") {
|
||||
endRow()
|
||||
continue
|
||||
}
|
||||
field += char
|
||||
}
|
||||
if (field !== "" || row.length > 0) endRow()
|
||||
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
|
||||
return { rows, total, columns }
|
||||
}
|
||||
|
||||
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
|
||||
export function blobUrlFromContent(content: FileContent) {
|
||||
const type = content.mimeType ?? "application/octet-stream"
|
||||
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
|
||||
const raw = atob(content.content)
|
||||
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
|
||||
return URL.createObjectURL(new Blob([bytes], { type }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
|
||||
* an absolute base an absolute one; undefined when the link climbs past the base's root.
|
||||
*/
|
||||
export function resolveArtifactPath(base: string, href: string) {
|
||||
const target = href.replaceAll("\\", "/")
|
||||
if (target.startsWith("/")) return undefined
|
||||
const dir = base.replaceAll("\\", "/")
|
||||
const segments = [...dir.split("/").filter(Boolean)]
|
||||
for (const segment of target.split("/")) {
|
||||
if (!segment || segment === ".") continue
|
||||
if (segment !== "..") {
|
||||
segments.push(segment)
|
||||
continue
|
||||
}
|
||||
if (segments.length === 0) return undefined
|
||||
segments.pop()
|
||||
}
|
||||
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { createPathHelpers } from "./path"
|
||||
import { fileContentFromBytes } from "./artifact"
|
||||
import {
|
||||
approxBytes,
|
||||
evictContentLru,
|
||||
@@ -185,14 +186,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
setLoading(file)
|
||||
|
||||
// Files outside the workspace are read from their own directory, like markdown images.
|
||||
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
|
||||
const request = path.absolute(file)
|
||||
? { path: getFilename(file), location: { directory: getDirectory(file) } }
|
||||
: { path: file, location: { directory } }
|
||||
const promise = serverSDK.api.file
|
||||
.read({ path: file, location: { directory } })
|
||||
.read(request)
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
const content = fileContentFromBytes(file, data)
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
touchFileContent(file, approxBytes(content))
|
||||
evictContent(new Set([file]))
|
||||
})
|
||||
@@ -281,6 +285,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return {
|
||||
ready: () => view().ready(),
|
||||
normalize: path.normalize,
|
||||
absolute: path.absolute,
|
||||
tab: path.tab,
|
||||
pathFromTab: path.pathFromTab,
|
||||
tree: {
|
||||
|
||||
@@ -21,6 +21,24 @@ describe("file path helpers", () => {
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
})
|
||||
|
||||
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
|
||||
const posix = createPathHelpers(() => "/repo")
|
||||
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
|
||||
expect(posix.absolute("src/app.ts")).toBe(false)
|
||||
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
|
||||
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
|
||||
|
||||
const windows = createPathHelpers(() => "C:\\repo")
|
||||
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
|
||||
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
|
||||
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
})
|
||||
|
||||
test("normalizes Windows directory separators", () => {
|
||||
const path = createPathHelpers(() => "C:\\repo")
|
||||
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
||||
|
||||
@@ -105,7 +105,11 @@ export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope()
|
||||
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
|
||||
/^[/\\]([A-Za-z]:)/,
|
||||
"$1",
|
||||
)
|
||||
|
||||
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
|
||||
// Only case-insensitive on Windows (drive letter or UNC paths)
|
||||
@@ -116,20 +120,21 @@ export function createPathHelpers(scope: () => string) {
|
||||
canonPath.startsWith(canonRoot) &&
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
|
||||
) {
|
||||
// Slice from original path to preserve native separators
|
||||
path = path.slice(root.length)
|
||||
// Slice from original path to preserve native separators, then drop the separator itself.
|
||||
path = path.slice(root.length).replace(/^[/\\]/, "")
|
||||
}
|
||||
|
||||
if (path.startsWith("./") || path.startsWith(".\\")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
|
||||
return path
|
||||
}
|
||||
|
||||
/** Whether a normalized path points outside the workspace root. */
|
||||
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
|
||||
|
||||
const tab = (input: string) => {
|
||||
const path = normalize(input)
|
||||
return `file://${encodeFilePath(path)}`
|
||||
@@ -149,6 +154,7 @@ export function createPathHelpers(scope: () => string) {
|
||||
|
||||
return {
|
||||
normalize,
|
||||
absolute,
|
||||
tab,
|
||||
pathFromTab,
|
||||
normalizeDir,
|
||||
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -17,6 +18,24 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
}
|
||||
}
|
||||
|
||||
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
|
||||
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
|
||||
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
|
||||
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
|
||||
|
||||
/** @type {import("rolldown").Plugin} */
|
||||
const bundleNestedWorkerDeps = {
|
||||
name: "opencode-desktop:bundle-nested-worker-deps",
|
||||
resolveId(id, importer) {
|
||||
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
|
||||
try {
|
||||
return createRequire(importer).resolve(id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
@@ -44,8 +63,9 @@ export default [
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
|
||||
exclude: workerDeps,
|
||||
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
|
||||
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
await waitForPlugin(info.url, headers, plugin)
|
||||
|
||||
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
@@ -139,7 +139,13 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
||||
}
|
||||
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), milliseconds)
|
||||
process.exited.then(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function pluginSource() {
|
||||
@@ -159,11 +165,15 @@ async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||
async function waitForPlugin(url: string, headers: HeadersInit, plugin: string) {
|
||||
const deadline = Date.now() + 10_000
|
||||
let attempt = 0
|
||||
while (Date.now() < deadline) {
|
||||
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||
await Bun.sleep(25)
|
||||
// Native watchers may coalesce a single creation edge. Keep changing valid source so
|
||||
// the smoke proves that a later native event is delivered.
|
||||
if (++attempt % 10 === 0) await fs.writeFile(plugin, `${pluginSource()}// watcher retry ${attempt}\n`)
|
||||
}
|
||||
throw new Error("Compiled service did not discover the created plugin")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user