mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 11:36:14 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0215d1ca5 | ||
|
|
785e05e54e | ||
|
|
1c4d9a983d |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -195,33 +195,9 @@ jobs:
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-app-archive:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -234,7 +210,6 @@ jobs:
|
||||
host: macos-26
|
||||
- target: windows-arm64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
bun_install_flags: --cpu=*
|
||||
- target: windows-x64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
@@ -246,19 +221,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||
install-flags: --os=* --cpu=*
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache
|
||||
|
||||
- name: Build
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -573,7 +543,6 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-app-archive
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
id: setup-bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
@@ -86,7 +85,7 @@ jobs:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -191,7 +190,7 @@
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -525,7 +524,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -594,8 +593,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"@opentui/core": ">=0.5.6",
|
||||
"@opentui/solid": ">=0.5.6",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -718,6 +717,7 @@
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
@@ -727,7 +727,6 @@
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -1078,10 +1077,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1089,9 +1088,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1118,7 +1117,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1685,13 +1684,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2217,27 +2216,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6" }, "peerDependencies": { "@opentui/react": "0.5.6", "@opentui/solid": "0.5.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1lR1aFRSgHCwN31YOHDQQVSqz1YNN7DsoIvWHjEiQJ12kTxHg9G7Q1HHFJttM838RWsNOvW6gBAhOr403VL8rg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kxmrQS31sgziW/qZDuFYuiT3CvcUQg9ZQA1qKCEb5mBoInur4fnECOdTaM2S8c3cidcV0z2yu41NP1Dypkl+BQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3853,7 +3852,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -6173,8 +6172,6 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
|
||||
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
|
||||
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
|
||||
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
|
||||
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
|
||||
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
|
||||
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
|
||||
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -39,19 +39,19 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
|
||||
@@ -41,7 +41,6 @@ const SSE_EVENTS = new Set([
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
@@ -1005,13 +1004,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
}),
|
||||
)
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -41,13 +41,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||
const omitsFunctionCallIds = (modelID: string) => {
|
||||
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -82,15 +75,10 @@ export type ProviderOptionsInput = OptionsInput
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
// Gemini is known to send explicit `null` for optional streaming fields
|
||||
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||
// so the shared schemas stay safe there.
|
||||
const GeminiTextPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
thought: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -103,11 +91,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -127,8 +115,8 @@ const GeminiContentPart = Schema.Union([
|
||||
])
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
@@ -191,33 +179,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: optionalNull(GeminiContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
@@ -229,7 +217,6 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -287,14 +274,20 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
@@ -310,8 +303,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const previous = contents.at(-1)
|
||||
// Gemini rejects a continuation whose function-response turn carries extra
|
||||
// parts, so an update after a tool result starts its own user turn.
|
||||
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
}
|
||||
@@ -343,7 +336,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const lowered = lowerToolCall(part)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -368,7 +361,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -389,7 +382,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -402,8 +395,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
@@ -493,25 +486,21 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
const promptTokens = usage.promptTokenCount ?? undefined
|
||||
const cached = usage.cachedContentTokenCount ?? undefined
|
||||
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||
const visible = usage.candidatesTokenCount ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: promptTokens,
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
@@ -546,10 +535,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||
// the "nothing to finish" check below keeps its meaning.
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
@@ -595,10 +581,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts ?? []) {
|
||||
for (const part of candidate.content.parts) {
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
@@ -634,14 +618,13 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
@@ -654,8 +637,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -669,7 +651,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -62,11 +61,6 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -248,7 +242,6 @@ const OpenResponsesErrorPayload = Schema.Struct({
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
@@ -313,6 +306,20 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
const RefusalEvent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.delta"),
|
||||
item_id: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.done"),
|
||||
item_id: Schema.String,
|
||||
refusal: Schema.String,
|
||||
}),
|
||||
])
|
||||
const isRefusalEvent = Schema.is(RefusalEvent)
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -333,6 +340,7 @@ export interface ParserState {
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
@@ -410,6 +418,10 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
}
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
return itemID(part.providerMetadata, providerMetadataKey)
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
@@ -429,7 +441,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
@@ -580,8 +592,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
@@ -591,7 +604,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
),
|
||||
})
|
||||
}
|
||||
if (id) hostedToolReferences.add(id)
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -803,17 +816,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id) {
|
||||
const phase = messagePhase(item.phase)
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -971,7 +985,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
@@ -1079,45 +1093,32 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// Build a single human-readable message 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 => {
|
||||
// to be indistinguishable from generic stream drops.
|
||||
const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
// Keep the full raw payload on the error even when the message is a summary.
|
||||
const body = JSON.stringify(nested ?? event) ?? ""
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
const reason =
|
||||
event.type === "error" &&
|
||||
event.error === undefined &&
|
||||
event.response === undefined &&
|
||||
summary === undefined &&
|
||||
status === undefined
|
||||
? new ProviderInternalReason({ message })
|
||||
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason,
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1133,19 +1134,21 @@ export const step = (state: ParserState, event: Event) => {
|
||||
)
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string")
|
||||
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
@@ -1190,11 +1193,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase,
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -156,9 +156,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -207,16 +204,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
@@ -517,17 +509,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||
// whenever the conversation history contains tool calls/results. Send an
|
||||
// explicit empty array when we have history but no active tools.
|
||||
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool") return true
|
||||
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
return {
|
||||
@@ -551,15 +532,12 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
@@ -603,18 +581,11 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached =
|
||||
(usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -720,11 +691,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
|
||||
@@ -134,12 +134,13 @@ const HOSTED_TOOLS = {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
|
||||
@@ -28,11 +28,7 @@ export const ResponseIncludables = [
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -42,7 +38,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -80,9 +80,6 @@ export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||
// that the summary message does not carry. Not shown to users.
|
||||
readonly rawBody?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
@@ -92,13 +89,11 @@ export interface ProviderFailure {
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
const text = body || input.message
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
|
||||
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const 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 = {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
@@ -153,9 +153,6 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
// Raw provider payload as a string, so classified failures never lose the
|
||||
// original error detail even when the pretty message is a summary.
|
||||
body: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
-31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"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\": [{\"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"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\"}}}}]}]}"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -106,20 +106,3 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
|
||||
})
|
||||
expect(reason._tag).toBe("InvalidRequest")
|
||||
expect(reason).toMatchObject({ classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("extracts nested codes from the raw payload", () => {
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,4 +4,10 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { Azure } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
|
||||
|
||||
const chatModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).chat("gpt-5.6-luna")
|
||||
|
||||
const responsesModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).responses("gpt-5.6-luna")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "azure",
|
||||
provider: "azure",
|
||||
requires: ["AZURE_OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Azure OpenAI recorded", () => {
|
||||
recorded.effect("chat streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("bonjour")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -156,13 +156,14 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -200,8 +201,8 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "time" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,12 +210,14 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
@@ -225,104 +228,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers function call ids for gemini 3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
]
|
||||
const legacy = yield* compileRequest(LLM.request({ model, messages }))
|
||||
const older = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-1.5-flash" }),
|
||||
messages,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(legacy.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
|
||||
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes function call ids for non-gemini model ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemma-3-27b-it" }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -514,16 +419,13 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
],
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
@@ -947,7 +849,7 @@ describe("Gemini route", () => {
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call",
|
||||
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
@@ -955,7 +857,7 @@ describe("Gemini route", () => {
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
@@ -971,6 +873,7 @@ describe("Gemini route", () => {
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1074,7 +977,7 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1084,7 +987,7 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "tool_0",
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -1120,15 +1023,15 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
@@ -1156,11 +1059,11 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1311,6 +1214,7 @@ describe("Gemini route", () => {
|
||||
id: "call_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "call_0" } },
|
||||
})
|
||||
expect(response.toolCalls[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
@@ -1326,37 +1230,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
id: "dup_call",
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
@@ -1492,73 +1365,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives explicit null usage counts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
|
||||
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
|
||||
expect(response.usage?.inputTokens).toBeUndefined()
|
||||
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
|
||||
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
|
||||
expect(response.usage?.reasoningTokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives null candidates, content, parts, and finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: null },
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null thought flag on a text part as visible output", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
|
||||
|
||||
expect(reasoningStart).toBeUndefined()
|
||||
expect(response.reasoning ?? "").toBe("")
|
||||
expect(response.text).toBe("Visible")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails invalid stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = GoogleVertex.configure({
|
||||
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.5-flash")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_VERTEX_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Vertex Gemini recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -75,53 +75,6 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
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: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -342,7 +342,6 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||
],
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -78,45 +78,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
|
||||
},
|
||||
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -153,12 +114,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
|
||||
],
|
||||
})
|
||||
}),
|
||||
@@ -233,7 +189,6 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -258,7 +213,6 @@ describe("Open Responses-compatible route", () => {
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
service_tier: "provider-tier",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
|
||||
@@ -188,11 +188,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1271,7 +1273,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1298,7 +1300,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1331,7 +1333,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1356,7 +1358,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2650,47 +2652,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "computer_call",
|
||||
id: "computer_1",
|
||||
call_id: "call_1",
|
||||
status: "completed",
|
||||
action: { type: "click", x: 100, y: 200 },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
input: { type: "click", x: 100, y: 200 },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
@@ -2804,7 +2765,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2835,7 +2796,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2860,7 +2821,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
file_data: "AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3055,42 +3016,36 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the raw payload when error is null", () =>
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies a detail-free error event as a transient provider failure", () =>
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("hello"),
|
||||
})
|
||||
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
|
||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ tool },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
|
||||
@@ -19,12 +19,6 @@
|
||||
|
||||
- Always prefer `createStore` over multiple `createSignal` calls
|
||||
|
||||
## Typography
|
||||
|
||||
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
|
||||
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
|
||||
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
|
||||
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
|
||||
@@ -259,22 +259,6 @@ export function event(
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
|
||||
return makeEvent("session.compaction.started", data)
|
||||
}
|
||||
|
||||
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
|
||||
return makeEvent("session.compaction.delta", data)
|
||||
}
|
||||
|
||||
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
|
||||
return makeEvent("session.compaction.ended", data)
|
||||
}
|
||||
|
||||
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
|
||||
return makeEvent("session.compaction.failed", data)
|
||||
}
|
||||
|
||||
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
|
||||
return makeEvent("session.tool.input.started", data)
|
||||
}
|
||||
|
||||
@@ -53,37 +53,6 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, { draftID })
|
||||
await page.goto("/")
|
||||
|
||||
const draftHref = stressDraftHref(draftID)
|
||||
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
|
||||
await expect(draftTab).toHaveCount(1)
|
||||
await draftTab.click()
|
||||
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
|
||||
await expect(sessionTab).toHaveCount(1)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: '[data-component="new-session"]',
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await sessionTab.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
|
||||
@@ -45,17 +45,21 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(comment).toHaveCount(1)
|
||||
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.dispatchEvent("click")
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await lineNumber.hover()
|
||||
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||
await expect(comment).toHaveCount(1)
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.focus()
|
||||
await expect(comment).toBeFocused()
|
||||
}).toPass({ timeout: 10_000 })
|
||||
await comment.press("Enter")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
@@ -179,7 +179,6 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
}
|
||||
|
||||
async function expectSideGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
|
||||
@@ -189,20 +188,15 @@ async function expectSideGeometry(page: Page) {
|
||||
terminalLeft: terminal.left,
|
||||
terminalRight: terminal.right,
|
||||
terminalTop: terminal.top,
|
||||
terminalBottom: terminal.bottom,
|
||||
reviewTop: review.top,
|
||||
reviewBottom: review.bottom,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectBottomGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
@@ -232,30 +226,6 @@ async function expectBottomGeometry(page: Page) {
|
||||
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
|
||||
}
|
||||
|
||||
async function expectPanelGap(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
const gap = terminal.top - review.bottom
|
||||
return gap
|
||||
})
|
||||
})
|
||||
.toBeGreaterThanOrEqual(expected - 1)
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
})
|
||||
})
|
||||
.toBeLessThanOrEqual(expected + 1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
@@ -82,9 +82,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const id = "prt_file_projection_anchored_patch"
|
||||
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
||||
await setupTimeline(page, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
@@ -105,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
|
||||
]),
|
||||
],
|
||||
viewport: { width: 1200, height: 600 },
|
||||
@@ -112,21 +113,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const row = page.locator("[data-timeline-key]", { has: wrapper })
|
||||
const trigger = wrapper.getByRole("button")
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(1)
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
|
||||
})
|
||||
@@ -134,28 +121,11 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -20)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
|
||||
.toBeGreaterThan(0)
|
||||
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
|
||||
await trigger.dispatchEvent("pointerdown")
|
||||
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
|
||||
await trigger.click()
|
||||
await trigger.dispatchEvent("click")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element, collapsed) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
const allocatedHeight = element.getBoundingClientRect().height
|
||||
return {
|
||||
grew: allocatedHeight > collapsed + 1,
|
||||
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
|
||||
}
|
||||
}, collapsedHeight),
|
||||
)
|
||||
.toEqual({ grew: true, measured: true })
|
||||
await expect
|
||||
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
|
||||
.toBeLessThanOrEqual(5)
|
||||
@@ -163,10 +133,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
|
||||
.toBeGreaterThan(50)
|
||||
await timeline.settle(40)
|
||||
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(50)
|
||||
expect(scrolled).toBeLessThan(400)
|
||||
|
||||
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
@@ -181,16 +150,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 20
|
||||
const messagePageSize = 200
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
@@ -82,102 +73,23 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
sessionID,
|
||||
text: "## Checkpoint\n\nStreamed implementation details.",
|
||||
}),
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Checkpoint\n\nFinal implementation details.",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
content: [toolPart("call_subagent", "subagent", "streaming", {})],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Delegating agent...",
|
||||
)
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
|
||||
@@ -215,11 +127,11 @@ test("renders the moved location notice in its compact timeline style", async ({
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("line-height", "13px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
await expect(value).toHaveCSS("font-weight", "440")
|
||||
await expect(value).toHaveCSS("line-height", "16px")
|
||||
await expect(value).toHaveCSS("line-height", "13px")
|
||||
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(value).toHaveCSS("white-space", "nowrap")
|
||||
|
||||
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
const rows = group.locator('[data-component="tool-trigger"]')
|
||||
await expect(rows.nth(0)).toContainText("(1 match)")
|
||||
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
@@ -111,11 +111,7 @@ test("labels read tools from their path input", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
@@ -125,20 +121,19 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "frontend-design" }),
|
||||
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
for (const [id, name] of [
|
||||
[pending, "frontend-design"],
|
||||
[pending, "sample-skill"],
|
||||
[completed, "OpenCode"],
|
||||
] as const) {
|
||||
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const loaded = skill.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
|
||||
await expect(loaded).toHaveCSS("line-height", "16px")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
|
||||
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
|
||||
|
||||
@@ -26,42 +26,6 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("returns to the parent session with Escape", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
})
|
||||
|
||||
test("shows parent lineage while the child timeline loads", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.pathname === `/api/session/${childID}/message` &&
|
||||
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
|
||||
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
|
||||
]).finally(() => release.resolve())
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
})
|
||||
|
||||
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
@@ -86,44 +50,6 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
})
|
||||
|
||||
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await page.goBack()
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
|
||||
const parentTab = page.locator("[data-titlebar-tab-slot]", {
|
||||
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
|
||||
})
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
expect(parentTab).toHaveAttribute("data-active", "true"),
|
||||
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
|
||||
]).finally(() => release.resolve())
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
const home = page.getByRole("button", { name: "Home" })
|
||||
await home.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
|
||||
await expect(childTab).toHaveCount(1)
|
||||
await childTab.click()
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
|
||||
@@ -10,13 +10,11 @@ const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const ptyInput: string[] = []
|
||||
let sendPtyOutput: ((data: string) => void) | undefined
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
ptyInput.length = 0
|
||||
sendPtyOutput = undefined
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -76,7 +74,6 @@ test.beforeEach(async ({ page }) => {
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
ws.onMessage((message) => ptyInput.push(message.toString()))
|
||||
sendPtyOutput = (data) => ws.send(data)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,33 +90,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
await expect.poll(() => ptyInput.join("")).toBe("\x15")
|
||||
})
|
||||
|
||||
test("hides the native contenteditable caret", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toHaveAttribute("contenteditable", "true")
|
||||
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
|
||||
})
|
||||
|
||||
test("reveals the terminal after its first server output renders", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toHaveAttribute("contenteditable", "true")
|
||||
await expect(terminal).toHaveCSS("opacity", "0")
|
||||
await expect.poll(() => sendPtyOutput).toBeDefined()
|
||||
|
||||
sendPtyOutput?.("\x1b[?25h")
|
||||
await expect(terminal).toHaveCSS("opacity", "0")
|
||||
|
||||
sendPtyOutput?.("ready")
|
||||
await expect(terminal).toHaveCSS("opacity", "1")
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
@@ -243,8 +213,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -42,16 +42,6 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/animation.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
@@ -104,454 +94,24 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectWidthMotions(page, 1)
|
||||
await expectReviewWidthStable(page)
|
||||
await expectLogicalSideAlignment(page, "ltr")
|
||||
await page.evaluate(() => (document.documentElement.dir = "rtl"))
|
||||
await expectLogicalSideAlignment(page, "rtl")
|
||||
await page.evaluate(() => (document.documentElement.dir = "ltr"))
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
const terminalContent = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expectHeightMotions(page, "session-side-region", 1)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 1)
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
const reviewContent = page.locator('[data-component="session-review-v2"]')
|
||||
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectHeightMotions(page, "session-side-region", 3)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 3)
|
||||
|
||||
await resetTerminalContentSizes(page)
|
||||
await resetPanelGaps(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
|
||||
await expectHeightMotions(page, "session-side-region", 4)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 4)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectTerminalContentCachedSize(page)
|
||||
await expectStackPainted(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
|
||||
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expectWidthMotions(page, 2)
|
||||
await expectSideSlideSettled(page, 2)
|
||||
await expectHiddenSideAligned(page)
|
||||
|
||||
await resetHeightMotions(page)
|
||||
await resetHorizontalScrolls(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expectWidthMotions(page, 3)
|
||||
await expectSideSlideSettled(page, 3)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 4)
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 5)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
|
||||
)
|
||||
})
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectAnimation(page, "terminal-panel-size-in")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expectAnimation(page, "terminal-panel-size-out")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
type MotionProbe = {
|
||||
widths: number
|
||||
widthEnds: number
|
||||
horizontalScrolls: number[]
|
||||
reviewWidths: number[]
|
||||
paintGaps: { review: number; terminalSurface: number }[]
|
||||
terminalContentSizes: { width: number; height: number }[]
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
}
|
||||
|
||||
async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
}
|
||||
const observed = new WeakSet<Element>()
|
||||
const observers: ResizeObserver[] = []
|
||||
const observeReview = () => {
|
||||
const review = document.querySelector('[data-component="session-review-v2"]')
|
||||
if (!review || observed.has(review)) return
|
||||
observed.add(review)
|
||||
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
|
||||
observer.observe(review)
|
||||
observers.push(observer)
|
||||
}
|
||||
const observedRegions = new WeakSet<Element>()
|
||||
const observeStack = () => {
|
||||
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
|
||||
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
|
||||
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
|
||||
observedRegions.add(reviewRegion)
|
||||
const observer = new ResizeObserver(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
height: terminalContent.getBoundingClientRect().height,
|
||||
})
|
||||
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
|
||||
if (probe.resetAnchorOnMotion) {
|
||||
if (anchorGap > 8) return
|
||||
probe.terminalAnchorGaps = []
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
|
||||
terminalSurface: Math.abs(
|
||||
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
|
||||
),
|
||||
})
|
||||
})
|
||||
observer.observe(reviewRegion)
|
||||
observer.observe(terminalRegion)
|
||||
observers.push(observer)
|
||||
}
|
||||
new MutationObserver(() => {
|
||||
observeReview()
|
||||
observeStack()
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
observeReview()
|
||||
observeStack()
|
||||
document.addEventListener("transitionrun", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const slot = event.target.getAttribute("data-slot")
|
||||
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
|
||||
if (event.propertyName === "height" && slot) {
|
||||
probe.heights.push(slot)
|
||||
}
|
||||
})
|
||||
document.addEventListener("transitionend", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
|
||||
probe.widthEnds++
|
||||
})
|
||||
document.addEventListener("animationstart", (event) => {
|
||||
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
|
||||
probe.animations.push(event.animationName)
|
||||
})
|
||||
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
|
||||
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
|
||||
})
|
||||
}
|
||||
|
||||
async function expectWidthMotions(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function resetHeightMotions(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.heights = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
const heights = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
|
||||
)
|
||||
expect(heights).toEqual([])
|
||||
}
|
||||
|
||||
async function expectHiddenSideAligned(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
|
||||
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
|
||||
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
|
||||
const row = chat.parentElement.getBoundingClientRect()
|
||||
const hidden = side.getBoundingClientRect()
|
||||
return Math.max(
|
||||
Math.abs(row.top - hidden.top),
|
||||
Math.abs(row.right - hidden.right),
|
||||
Math.abs(row.bottom - hidden.bottom),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetHorizontalScrolls(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.horizontalScrolls = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: Page) {
|
||||
const scrolls = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
|
||||
)
|
||||
expect(Math.max(0, ...scrolls)).toBe(0)
|
||||
expect(await page.evaluate(() => window.scrollX)).toBe(0)
|
||||
}
|
||||
|
||||
async function expectReviewWidthStable(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
const widths = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
|
||||
)
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
}
|
||||
|
||||
async function expectStackedGeometry(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(9)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(7)
|
||||
}
|
||||
|
||||
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((direction) => {
|
||||
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
|
||||
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
|
||||
if (!frame || !content) return Number.POSITIVE_INFINITY
|
||||
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
|
||||
}, direction),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectStackPainted(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalBottoms = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalBottomFixed(page: Page) {
|
||||
const bottoms = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
|
||||
)
|
||||
expect(bottoms.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalAnchorGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.resetAnchorOnMotion = true
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPanelGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.panelGaps = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectPanelGapHeld(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
|
||||
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectTerminalTopAnchored(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
|
||||
}
|
||||
|
||||
async function resetTerminalContentSizes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalContentSizes = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalContentCachedSize(page: Page) {
|
||||
const sizes = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
|
||||
)
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(slot) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
|
||||
.length ?? 0,
|
||||
slot,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectAnimation(page: Page, name: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(name) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
|
||||
name,
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -358,8 +358,7 @@ test.describe("smoke: session timeline", () => {
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
|
||||
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
@@ -42,10 +41,6 @@ export type ComposerSelection = {
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
handoff?: {
|
||||
set: (message: SessionMessageUser) => void
|
||||
clear: (messageID: string) => void
|
||||
}
|
||||
api: {
|
||||
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
|
||||
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
|
||||
@@ -56,7 +51,6 @@ export type ComposerSession = {
|
||||
location: { command: Pick<Data["location"]["command"], "list"> }
|
||||
session: {
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
|
||||
setStatus: Data["session"]["setStatus"]
|
||||
}
|
||||
}
|
||||
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -83,7 +77,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
|
||||
start: (
|
||||
selection: ComposerSelection,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
|
||||
) => Promise<ComposerSession | undefined>
|
||||
}
|
||||
|
||||
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Composer(props: {
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
variantControlVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ComposerEditorProps = {
|
||||
borderUnderlay?: boolean
|
||||
class?: string
|
||||
modelControl?: JSX.Element
|
||||
modelControlsVisible?: boolean
|
||||
variantControlVisible?: boolean
|
||||
attachKeybind?: string[]
|
||||
attachShortcut?: string
|
||||
}
|
||||
@@ -233,19 +233,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
{props.modelControl}
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<ComposerEditorSubmitButton
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
@@ -70,8 +69,6 @@ function submitInput(
|
||||
function session(input: {
|
||||
calls: string[]
|
||||
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
|
||||
handoff?: ComposerSession["handoff"]
|
||||
statuses?: ("idle" | "running")[]
|
||||
current?: ComposerSession["current"]
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
@@ -80,7 +77,6 @@ function session(input: {
|
||||
return {
|
||||
id: "session-1",
|
||||
directory: "C:/repo",
|
||||
handoff: input.handoff,
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
@@ -96,7 +92,6 @@ function session(input: {
|
||||
data: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
setStatus: (_sessionID, status) => input.statuses?.push(status),
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
await input.prompt(value)
|
||||
@@ -145,12 +140,10 @@ describe("Composer submission", () => {
|
||||
|
||||
test("starts and promotes a New Session once before admitting its first prompt", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
|
||||
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const cleanupReady = Promise.withResolvers<void>()
|
||||
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
|
||||
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
@@ -163,70 +156,18 @@ describe("Composer submission", () => {
|
||||
async start(_selection, submission) {
|
||||
calls.push("start")
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: cleanupReady.promise }
|
||||
},
|
||||
}
|
||||
|
||||
const submitted = submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
|
||||
expect(statuses).toEqual(["running"])
|
||||
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
|
||||
cleanupReady.resolve()
|
||||
await submitted
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("hands off image-only first prompts before admission", async () => {
|
||||
const draft = createMemoryComposerState().capture()
|
||||
draft.set([
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
const handedOff = Promise.withResolvers<SessionMessageUser>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
handoff: { set: handedOff.resolve, clear() {} },
|
||||
prompt: async () => undefined,
|
||||
})
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(await handedOff.promise).toMatchObject({
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
|
||||
name: "image.png",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
@@ -292,7 +233,7 @@ describe("Composer submission", () => {
|
||||
submitted() {},
|
||||
async start(_selection, submission) {
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +250,10 @@ describe("Composer submission", () => {
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
throw new Error("network unavailable")
|
||||
@@ -344,7 +283,6 @@ describe("Composer submission", () => {
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
@@ -66,43 +65,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const comments = input.comments.capture()
|
||||
|
||||
try {
|
||||
const started =
|
||||
const session =
|
||||
input.adapter.kind === "active-session"
|
||||
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
|
||||
? input.adapter.session()
|
||||
: await input.adapter.start(value.selection, submission)
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
if (!session) return
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sending.then((result) => {
|
||||
if (!result.ok)
|
||||
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
|
||||
if (value.mode === "shell") {
|
||||
@@ -111,6 +82,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const command = findCommand(session, value.text)
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command).catch((error) =>
|
||||
@@ -119,6 +91,14 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sendPrompt(session, value).catch((error) =>
|
||||
failSubmission(input, session, "prompt", error, restore, value.id),
|
||||
)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
@@ -130,29 +110,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
return {
|
||||
id: value.id,
|
||||
type: "user",
|
||||
text: value.text,
|
||||
files: value.images.map((image) => ({
|
||||
data: "",
|
||||
mime: image.mime,
|
||||
source: { type: "uri", uri: image.blob.url },
|
||||
name: image.sourcePath ?? image.filename,
|
||||
})),
|
||||
metadata: {
|
||||
displayText: value.text,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
...(value.selection.variant ? { variant: value.selection.variant } : {}),
|
||||
},
|
||||
},
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
@@ -356,11 +313,8 @@ function failSubmission(
|
||||
error: unknown,
|
||||
restore: () => boolean,
|
||||
messageID?: string,
|
||||
rollback?: () => void,
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
if (messageID) session.handoff?.clear(messageID)
|
||||
rollback?.()
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
|
||||
@@ -42,10 +42,6 @@ const clearAuthToken = () => {
|
||||
|
||||
const web = createWebPlatform(pkg.version)
|
||||
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
|
||||
@@ -30,7 +30,6 @@ export function HomeCommandPalette(props: {
|
||||
opened: server.projects.list,
|
||||
stored: () => server.sync.data.project,
|
||||
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
@@ -137,17 +137,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
showProjectName: () => !home.project.selected(),
|
||||
server: () => home.selection.value().server,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
lookup: async (sessionID: string) => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
const result = await ctx.sdk.api.session.get({ sessionID })
|
||||
if (result.time.archived) return
|
||||
return buildHomeSessionRecords({
|
||||
sessions: () => [result],
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
})[0]
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
|
||||
@@ -8,21 +8,13 @@ import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "../model"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
|
||||
import { looksLikeSessionID } from "@/session/search"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
value: "",
|
||||
focused: false,
|
||||
highlighted: "",
|
||||
exact: undefined as HomeSessionRecord | undefined,
|
||||
lookingUp: false,
|
||||
})
|
||||
let lookup = 0
|
||||
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let list: HTMLDivElement | undefined
|
||||
@@ -30,11 +22,9 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
const results = createMemo(() => {
|
||||
const value = query().toLowerCase()
|
||||
if (!value) return []
|
||||
const records = sessions.data
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
|
||||
return [state.exact, ...records]
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
@@ -60,7 +50,6 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
close()
|
||||
}),
|
||||
)
|
||||
onCleanup(() => lookup++)
|
||||
|
||||
command.register("home.search", () => [
|
||||
{
|
||||
@@ -78,26 +67,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
}
|
||||
|
||||
function close() {
|
||||
lookup++
|
||||
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
|
||||
}
|
||||
|
||||
function update(value: string) {
|
||||
const current = ++lookup
|
||||
const sessionID = value.trim()
|
||||
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
|
||||
if (!looksLikeSessionID(sessionID)) return
|
||||
setState("lookingUp", true)
|
||||
void sessions.session.lookup(sessionID).then(
|
||||
(record) => {
|
||||
if (current !== lookup) return
|
||||
setState({ exact: record, lookingUp: false })
|
||||
},
|
||||
() => {
|
||||
if (current !== lookup) return
|
||||
setState("lookingUp", false)
|
||||
},
|
||||
)
|
||||
setState({ value: "", focused: false })
|
||||
}
|
||||
|
||||
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
|
||||
@@ -111,11 +81,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
placeholder,
|
||||
open,
|
||||
focus,
|
||||
input: update,
|
||||
input: (value: string) => setState({ value, highlighted: "" }),
|
||||
close,
|
||||
},
|
||||
result: {
|
||||
loading: () => sessions.data.loading() || state.lookingUp,
|
||||
loading: sessions.data.loading,
|
||||
list: results,
|
||||
active,
|
||||
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
|
||||
|
||||
@@ -509,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div
|
||||
class={`
|
||||
shrink-0 text-[13px] leading-text-compact tracking-[-0.04px]
|
||||
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
|
||||
text-v2-text-text-base [font-weight:530]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("@ibm/plex/IBM-Plex-Mono/fonts/complete/woff2/IBMPlexMono-Text.woff2") format("woff2");
|
||||
font-weight: 440;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
/* WebKit excludes safe-area insets from dvh in installed apps. */
|
||||
#root {
|
||||
@@ -33,133 +25,6 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="true"] {
|
||||
animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="false"] {
|
||||
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
|
||||
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
|
||||
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-out {
|
||||
from {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { startTransition } from "solid-js"
|
||||
import type { NewSessionComposerAdapter } from "@/composer/adapter"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -10,13 +9,11 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
@@ -56,35 +53,26 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
if (!sessionDirectory) return
|
||||
|
||||
const created = data.session.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
const creation = created.request.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => {
|
||||
const created = await serverSDK.api.session
|
||||
.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(language, error),
|
||||
})
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
const afterCreation = async <T,>(run: () => Promise<T>) => {
|
||||
const result = await creation
|
||||
if (!result.ok) throw result.error
|
||||
return run()
|
||||
}
|
||||
const sessionKey = SessionStateKey.from(
|
||||
serverSDK.scope,
|
||||
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
|
||||
)
|
||||
const cleanupReady = startTransition(() => {
|
||||
})
|
||||
if (!created) return
|
||||
|
||||
data.session.remember(created)
|
||||
await startTransition(() => {
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined })
|
||||
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
|
||||
permission.enableAutoAccept(created.id, sessionDirectory)
|
||||
@@ -104,32 +92,13 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
|
||||
api: {
|
||||
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
|
||||
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
|
||||
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
|
||||
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
|
||||
},
|
||||
data: {
|
||||
location: data.location,
|
||||
session: {
|
||||
setStatus: data.session.setStatus,
|
||||
prompt: (input) =>
|
||||
data.session.prompt({
|
||||
...input,
|
||||
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
current: () => data.session.get(created.id),
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
},
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
api: serverSDK.api.session,
|
||||
data,
|
||||
current: () => data.session.get(created.id) ?? created,
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -142,27 +111,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
|
||||
let unsubscribe: VoidFunction | undefined
|
||||
return {
|
||||
set(message: SessionMessageUser) {
|
||||
unsubscribe?.()
|
||||
setSessionMessageHandoff(key, message)
|
||||
unsubscribe = event.on("session.inbox.enqueued", (item) => {
|
||||
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, message.id)
|
||||
})
|
||||
},
|
||||
clear(messageID: string) {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, messageID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionDirectory(input: {
|
||||
projectDirectory: string
|
||||
worktree: string
|
||||
|
||||
@@ -139,7 +139,7 @@ function ProviderTip() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
|
||||
@@ -190,7 +190,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
{(group) => (
|
||||
<Show when={group.items().length > 0}>
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{group.title}
|
||||
</div>
|
||||
<For each={group.items()}>
|
||||
@@ -198,7 +198,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={provider.id}
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-text-compact tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||
onMouseEnter={() => setStore("active", provider.id)}
|
||||
disabled={store.connecting !== undefined}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -24,7 +23,6 @@ export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
@@ -122,38 +120,16 @@ export const DialogManageModels: Component = () => {
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => {
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = () => searching() || !store.collapsed[group.category]
|
||||
|
||||
return (
|
||||
<div
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
>
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
{(group) => (
|
||||
<div class="settings-section" data-component="settings-models-provider">
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||
<h3 class="settings-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
class="me-6"
|
||||
class="mr-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
@@ -161,28 +137,26 @@ export const DialogManageModels: Component = () => {
|
||||
{group.items[0].provider.name}
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
|
||||
type Store = {
|
||||
user: User[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
}
|
||||
|
||||
const createModelsPersistedState = () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
return [store, setStore, ready] as const
|
||||
}
|
||||
|
||||
const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const providers = useProviders(() => directory())
|
||||
const models = useGlobal().models
|
||||
const store = models.store
|
||||
const setStore = models.set
|
||||
|
||||
const [store, setStore, ready] = createModelsPersistedState()
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
@@ -128,14 +148,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
(p) => p,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
return {
|
||||
ready: models.ready,
|
||||
ready,
|
||||
list,
|
||||
find,
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: models.recent,
|
||||
list: () => recentModels()!,
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -10,10 +10,9 @@ import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/d
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
@@ -21,8 +20,6 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -47,182 +44,70 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
onSelect: () => void
|
||||
action?: JSX.Element
|
||||
model?: ModelState
|
||||
}> = (props) => {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: props.onSelect,
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
search: "",
|
||||
active: "",
|
||||
collapsed: {} as Record<string, boolean>,
|
||||
})
|
||||
const models = createMemo(() => controller.models(store.search))
|
||||
const groups = createMemo(() => controller.groups(models()))
|
||||
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
|
||||
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
|
||||
setStore({ search: value, active: first ? modelKey(first) : "" })
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
const keys = visibleModels().map(modelKey)
|
||||
if (keys.length === 0) return
|
||||
const index = keys.indexOf(store.active)
|
||||
const start = index === -1 ? (delta > 0 ? -1 : 0) : index
|
||||
setStore("active", keys[(start + delta + keys.length) % keys.length])
|
||||
queueMicrotask(() => {
|
||||
scrollRef
|
||||
?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = visibleModels().find((item) => modelKey(item) === store.active)
|
||||
if (item) controller.select(item)
|
||||
}
|
||||
const models = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="shrink-0 px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={store.search}
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.search}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
<List
|
||||
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
|
||||
emptyMessage={language.t("dialog.model.empty")}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
props.onSelect()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={isFree(i.provider.id, i.cost)}>
|
||||
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => {
|
||||
const searching = () => store.search.length > 0
|
||||
const open = () => expanded(group.category)
|
||||
|
||||
return (
|
||||
<section class="settings-section" data-expanded={open() ? "" : undefined}>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={open()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, open())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="settings-row"
|
||||
data-option-key={modelKey(item)}
|
||||
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => setStore("active", modelKey(item))}
|
||||
onMouseLeave={() => setStore("active", "")}
|
||||
onClick={() => controller.select(item)}
|
||||
>
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="flex items-center gap-2">
|
||||
<Tooltip
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control" class="size-4">
|
||||
<Show when={controller.current() === modelKey(item)}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -546,25 +431,18 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="large" variant="settings">
|
||||
<DialogHeader hideClose closeLabel={language.t("common.close")}>
|
||||
<Dialog>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
<Button icon="plus" onClick={provider}>
|
||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<DialogBody>
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<div class="shrink-0 border-t border-v2-border-border-muted px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.manage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -570,13 +570,10 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Not yet",
|
||||
|
||||
"error.page.title": "Something went wrong",
|
||||
"error.page.title.status": "Server request failed",
|
||||
"error.page.description": "An error occurred while loading the application.",
|
||||
"error.page.description.status": "The server returned an HTTP {{status}} response.",
|
||||
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
|
||||
"error.page.details.label": "Error Details",
|
||||
"error.page.action.restart": "Restart",
|
||||
"error.page.action.reload": "Reload",
|
||||
"error.page.action.report": "Report Error",
|
||||
"error.page.action.reported": "Error Reported",
|
||||
"error.page.action.exportLogs": "Export Logs",
|
||||
@@ -951,8 +948,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource, createServerTransport } from "./client"
|
||||
import { createOpenCodeEventSource } from "./client"
|
||||
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
@@ -84,39 +84,3 @@ describe("server event stream", () => {
|
||||
second.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("rotates HTTP and PTY clients together", async () => {
|
||||
const requests: Array<{ url: string; authorization: string | null }> = []
|
||||
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
|
||||
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
|
||||
}) as typeof globalThis.fetch
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
|
||||
fetch,
|
||||
})
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.health.get()
|
||||
const replacement = transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
username: "opencode",
|
||||
password: "second",
|
||||
})
|
||||
await transport.api.health.get()
|
||||
|
||||
expect(replacement).toBe(transport.api)
|
||||
expect(transport.pty).not.toBe(initialPty)
|
||||
expect(transport.url).toBe("http://127.0.0.1:4200")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://127.0.0.1:4100/api/health",
|
||||
authorization: `Basic ${btoa("opencode:first")}`,
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:4200/api/health",
|
||||
authorization: `Basic ${btoa("opencode:second")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -72,12 +72,11 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const pty = createPtyClient(api, { url: server.http.url })
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
@@ -86,7 +85,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -94,48 +93,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
get url() {
|
||||
return transport.url
|
||||
},
|
||||
get api() {
|
||||
return transport.api
|
||||
},
|
||||
get pty() {
|
||||
return transport.pty
|
||||
},
|
||||
url: server.http.url,
|
||||
api,
|
||||
pty,
|
||||
connection,
|
||||
event: events.event,
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
|
||||
update(http: ServerConnection.HttpBase): ServerApi
|
||||
readonly url: string
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
return {
|
||||
update(http: ServerConnection.HttpBase) {
|
||||
state.current = build(http)
|
||||
return state.current.api
|
||||
},
|
||||
get url() {
|
||||
return state.current.http.url
|
||||
},
|
||||
get api() {
|
||||
return state.current.api
|
||||
},
|
||||
get pty() {
|
||||
return state.current.pty
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export namespace ServerConnection {
|
||||
http: HttpBase
|
||||
} & (
|
||||
| // Regular desktop server
|
||||
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
|
||||
{ variant: "base" }
|
||||
// WSL server (windows only)
|
||||
| {
|
||||
variant: "wsl"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, createEffect, createMemo, createRoot, getOwner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
@@ -10,7 +10,6 @@ import { createData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createServerPermissionState } from "@/session/requests/server-permission"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -25,7 +24,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
serverKey: undefined as ServerConnection.Key | undefined,
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -88,7 +86,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
models,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
},
|
||||
@@ -96,37 +93,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
|
||||
function createGlobalModels() {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<{
|
||||
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
|
||||
recent: Array<{ providerID: string; modelID: string }>
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
const [recent] = createResource(
|
||||
async () => {
|
||||
const value = store.recent
|
||||
await ready.promise
|
||||
return value
|
||||
},
|
||||
(value) => value,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
return {
|
||||
store,
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
}
|
||||
}
|
||||
|
||||
function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
|
||||
@@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryFn: async () => [],
|
||||
})
|
||||
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
path: () => loadPathQuery(scope, null, serverAPI().location),
|
||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
const connected = () => serverSDK.connection.status() === "connected"
|
||||
|
||||
const [configQuery, pathQuery] = useQueries(() => ({
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
padding: 48px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function createActiveComposerAdapter(input: {
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
|
||||
const prompt = useComposerState()
|
||||
prompt.current()
|
||||
const state = prompt.capture()
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
|
||||
@@ -101,10 +101,6 @@ export function createActiveSessionRegion(input: {
|
||||
const focus = () => {
|
||||
if (!input.session.data.isChild()) promptRef?.focus()
|
||||
}
|
||||
const openParent = () => {
|
||||
const id = input.session.data.parentID()
|
||||
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
|
||||
}
|
||||
const editable = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
@@ -117,7 +113,6 @@ export function createActiveSessionRegion(input: {
|
||||
return current instanceof HTMLElement ? current : undefined
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
const path = event.composedPath()
|
||||
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
|
||||
const active = activeElement()
|
||||
@@ -129,11 +124,6 @@ export function createActiveSessionRegion(input: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape" && input.session.data.isChild()) {
|
||||
event.preventDefault()
|
||||
openParent()
|
||||
return
|
||||
}
|
||||
if (active === promptRef) {
|
||||
if (event.key === "Escape") promptRef?.blur()
|
||||
return
|
||||
@@ -182,7 +172,10 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
region: {
|
||||
centered: input.screen.centered,
|
||||
openParent,
|
||||
openParent: () => {
|
||||
const id = input.session.data.parentID()
|
||||
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
|
||||
},
|
||||
prompt,
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
@@ -211,7 +204,9 @@ export function ActiveSessionComposerRegion(props: {
|
||||
}) {
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
sessionKey: props.session.identity.sessionKey,
|
||||
sessionID: () => props.session.identity.params.id,
|
||||
prompt: props.model.region.prompt,
|
||||
centered: props.model.region.centered,
|
||||
onResponseSubmit: props.onResponseSubmit,
|
||||
openParent: props.model.region.openParent,
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
|
||||
import type { useComposerState } from "@/composer/persistence"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/session/handoff"
|
||||
import type { SessionRequestModel } from "../requests/model"
|
||||
|
||||
export function createSessionComposerRegionController(input: {
|
||||
state: SessionRequestModel
|
||||
parentID: Accessor<string | undefined>
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
prompt: ReturnType<typeof useComposerState>
|
||||
centered: Accessor<boolean>
|
||||
onResponseSubmit: () => void
|
||||
openParent: () => void
|
||||
setPromptRef: (el: HTMLDivElement) => void
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
setSessionHandoff(input.sessionKey(), {
|
||||
prompt: input.prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? data.session.get(id)?.parentID : undefined
|
||||
})
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
@@ -17,9 +49,11 @@ export function createSessionComposerRegionController(input: {
|
||||
openParent: input.openParent,
|
||||
setPromptRef: input.setPromptRef,
|
||||
setDockRef: input.setDockRef,
|
||||
parentID: input.parentID,
|
||||
child: () => !!input.parentID(),
|
||||
showComposer: () => !input.state.blocked() || !!input.parentID(),
|
||||
parentID,
|
||||
child: () => !!parentID(),
|
||||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export type SessionComposerRegionViewController = Pick<
|
||||
| "parentID"
|
||||
| "child"
|
||||
| "showComposer"
|
||||
| "handoffPrompt"
|
||||
| "promptReady"
|
||||
> & { state: SessionComposerRegionState }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
@@ -36,7 +38,7 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-[1000px] md:mx-auto": controller.centered(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={controller.state.questionRequest()} keyed>
|
||||
@@ -63,32 +65,43 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={controller.showComposer()}>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
<Show
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
<Show
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={controller.parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={controller.openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={controller.parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={controller.openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SessionSidePanel(props: {
|
||||
fileBrowserState: SessionFileBrowserState
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewPresent?: boolean
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
}) {
|
||||
@@ -79,7 +79,6 @@ export function SessionSidePanel(props: {
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
|
||||
const fileOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
@@ -89,12 +88,11 @@ export function SessionSidePanel(props: {
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const visible = createMemo(() => reviewVisible() || fileOpen())
|
||||
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!visible()) return "0px"
|
||||
if (reviewVisible()) return "auto"
|
||||
if (!open()) return "0px"
|
||||
if (reviewOpen()) return "auto"
|
||||
return `${fileTreeWidth()}px`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
@@ -254,14 +252,14 @@ export function SessionSidePanel(props: {
|
||||
"h-full min-h-0": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
"flex-1": reviewVisible(),
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
"flex-1": reviewOpen(),
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
<Show when={open()}>
|
||||
<div class="size-full flex">
|
||||
<Show when={reviewVisible()}>
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
<DragDropProvider
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { clearSessionMessageHandoff, getSessionMessageHandoff, setSessionMessageHandoff } from "./handoff"
|
||||
|
||||
test("stores and clears a message handoff", () => {
|
||||
const message = {
|
||||
id: "msg_handoff",
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [{ data: "", mime: "image/png", source: { type: "uri", uri: "blob:image" } }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
setSessionMessageHandoff("session-key", message)
|
||||
expect(getSessionMessageHandoff("session-key")).toEqual(message)
|
||||
clearSessionMessageHandoff("session-key", message.id)
|
||||
expect(getSessionMessageHandoff("session-key")).toBeUndefined()
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
type HandoffSession = {
|
||||
prompt: string
|
||||
files: Record<string, SelectedLineRange | null>
|
||||
}
|
||||
|
||||
@@ -12,8 +11,6 @@ const store = {
|
||||
session: new Map<string, HandoffSession>(),
|
||||
terminal: new Map<string, string[]>(),
|
||||
}
|
||||
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
|
||||
const messageOrder = new Map<string, true>()
|
||||
|
||||
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||
map.delete(key)
|
||||
@@ -26,32 +23,12 @@ const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||
}
|
||||
|
||||
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||
const prev = store.session.get(key) ?? { files: {} }
|
||||
const prev = store.session.get(key) ?? { prompt: "", files: {} }
|
||||
touch(store.session, key, { ...prev, ...patch })
|
||||
}
|
||||
|
||||
export const getSessionHandoff = (key: string) => store.session.get(key)
|
||||
|
||||
export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => {
|
||||
messageOrder.delete(key)
|
||||
messageOrder.set(key, true)
|
||||
setMessages(key, message)
|
||||
while (messageOrder.size > MAX) {
|
||||
const first = messageOrder.keys().next().value
|
||||
if (first === undefined) return
|
||||
messageOrder.delete(first)
|
||||
setMessages(first, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionMessageHandoff = (key: string) => messages[key]
|
||||
|
||||
export const clearSessionMessageHandoff = (key: string, messageID: string) => {
|
||||
if (messages[key]?.id !== messageID) return
|
||||
messageOrder.delete(key)
|
||||
setMessages(key, undefined)
|
||||
}
|
||||
|
||||
export const setTerminalHandoff = (key: string, value: string[]) => {
|
||||
touch(store.terminal, key, value)
|
||||
}
|
||||
|
||||
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
|
||||
|
||||
const textarea = terminal.querySelector("textarea")
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus({ preventScroll: true })
|
||||
textarea.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
terminal.focus({ preventScroll: true })
|
||||
terminal.focus()
|
||||
terminal.dispatchEvent(
|
||||
typeof PointerEvent === "function"
|
||||
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
} from "./session-domain"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const emptyUserMessages: SessionMessageUser[] = []
|
||||
@@ -25,8 +23,6 @@ const idle = { type: "idle" as const }
|
||||
export function useSessionModel() {
|
||||
const file = useFile()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const shellTabs = useTabs()
|
||||
const layout = useSessionLayout()
|
||||
const location = useWorkspaceLocation()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -35,16 +31,7 @@ export function useSessionModel() {
|
||||
const id = sessionID()
|
||||
return id ? data.session.get(id) : undefined
|
||||
})
|
||||
const parentID = createMemo(() => {
|
||||
const current = info()?.parentID
|
||||
if (current) return current
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
const tab = shellTabs.store.find(
|
||||
(item) => item.type === "session" && item.server === server.key && item.routeSessionId === id,
|
||||
)
|
||||
return tab?.type === "session" ? (tab.routeParentId ?? tab.sessionId) : undefined
|
||||
})
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? data.session.get(id) : undefined
|
||||
|
||||
@@ -124,12 +124,11 @@ export function createSessionReview(input: {
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
|
||||
})
|
||||
}, 100)
|
||||
createEffect(() => {
|
||||
const stop = location().event.listen((event) => {
|
||||
onCleanup(
|
||||
location().event.listen((event) => {
|
||||
if (event.type === "filesystem.changed") refresh()
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
}),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => input.screen.review.open() || mobileChanges(),
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -79,7 +79,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
|
||||
fileBrowserState={props.review.panelState}
|
||||
activeDiff={props.review.activeFile()}
|
||||
focusReviewDiff={props.review.focusFile}
|
||||
reviewPresent={props.present}
|
||||
reviewSnap={props.review.screen.review.snap()}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,12 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { useNotification } from "@/shell/notifications/notification"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
@@ -14,8 +15,7 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { useSessionModel } from "./model"
|
||||
import { SessionPanelFrame } from "./session-frame"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
|
||||
import { IncompatibleServerPanel } from "./incompatible-server-panel"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
@@ -31,7 +31,7 @@ export function TargetSessionRouteContent() {
|
||||
<MarkSessionNotificationsViewed sessionID={() => params.id} />
|
||||
<ModelsProvider directory={directory}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</ModelsProvider>
|
||||
@@ -45,14 +45,16 @@ function TargetSessionSettingsCommand() {
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<SessionStatePanel>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionStatePanel>
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -76,16 +78,18 @@ function ResolvedTargetSessionRoute() {
|
||||
<Show
|
||||
when={!server.health?.incompatible}
|
||||
fallback={
|
||||
<SessionStatePanel>
|
||||
<IncompatibleServerPanel
|
||||
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
|
||||
/>
|
||||
</SessionStatePanel>
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised>
|
||||
<IncompatibleServerPanel
|
||||
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
|
||||
/>
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
}
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value}>
|
||||
<LocationProvider directory={value()}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
@@ -96,35 +100,24 @@ function ResolvedTargetSessionRoute() {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingSessionState(props: { sessionID: string }) {
|
||||
return (
|
||||
<SessionStatePanel>
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
</SessionStatePanel>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
|
||||
return (
|
||||
// These providers select their scoped state reactively and retain bounded caches,
|
||||
// so keep their owners alive while navigating between workspaces on this server.
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
// Keep workspace-scoped file, prompt, comment, and terminal state alive when
|
||||
// the user switches between Sessions in the same workspace.
|
||||
<Show when={`${server.scope}\0${location().directory}`} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createComputed, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -15,9 +14,11 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
|
||||
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
|
||||
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
|
||||
const sideTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "bottom")
|
||||
const sideTerminalOpen = createMemo(() => terminalOpen() && sideTerminal())
|
||||
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
|
||||
const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminalOpen = createMemo(
|
||||
() => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom",
|
||||
)
|
||||
const fileTreeOpen = createMemo(
|
||||
() =>
|
||||
session.isDesktop() &&
|
||||
@@ -28,14 +29,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
|
||||
const [rowWidth, setRowWidth] = createSignal<number>()
|
||||
let row: HTMLDivElement | undefined
|
||||
createResizeObserver(
|
||||
() => row,
|
||||
({ width, height }) => setRowSize({ width, height }),
|
||||
({ width }) => setRowWidth(width),
|
||||
)
|
||||
const available = createMemo<number | undefined>(() => {
|
||||
const width = rowSize.width
|
||||
const width = rowWidth()
|
||||
if (width === undefined) return undefined
|
||||
return width - 8
|
||||
})
|
||||
@@ -64,30 +65,24 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
|
||||
createEffect((previous) => {
|
||||
const stacked = panelLayout().stacked
|
||||
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
|
||||
return stacked
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
|
||||
const sideHeight = createMemo(() => rowSize.height)
|
||||
const fullSideHeight = createMemo(() => (sideHeight() === undefined ? "100%" : `${sideHeight()}px`))
|
||||
const stackedReviewHeight = createMemo(() => {
|
||||
const height = sideHeight()
|
||||
if (height === undefined) return `calc(100% - ${terminalPaneHeight()} - 8px)`
|
||||
return `${Math.max(0, height - terminalPane() - 8)}px`
|
||||
const [reviewSnap, setReviewSnap] = createSignal(false)
|
||||
let reviewFrame: number | undefined
|
||||
createComputed((previous) => {
|
||||
const open = reviewOpen()
|
||||
if (previous === undefined || previous === open) return open
|
||||
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
setReviewSnap(true)
|
||||
reviewFrame = requestAnimationFrame(() => {
|
||||
reviewFrame = undefined
|
||||
setReviewSnap(false)
|
||||
})
|
||||
return open
|
||||
}, reviewOpen())
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
})
|
||||
const sideContentWidth = createMemo<string>((previous) => {
|
||||
const width = available()
|
||||
if (resizable() && width !== undefined) return `${Math.max(0, width - resizedWidth())}px`
|
||||
if (fileTreeOpen()) return `${layout.fileTree.width()}px`
|
||||
return previous
|
||||
}, "100%")
|
||||
|
||||
return {
|
||||
centered: createMemo(() => session.isDesktop()),
|
||||
files: { open: fileTreeOpen },
|
||||
@@ -104,36 +99,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
review: {
|
||||
open: reviewOpen,
|
||||
panelOpen: reviewPanelOpen,
|
||||
snap: reviewSnap,
|
||||
},
|
||||
side: {
|
||||
contentWidth: sideContentWidth,
|
||||
gap: {
|
||||
closing: () => motion.closing,
|
||||
height: createMemo(() => (motion.gap ? "8px" : "0px")),
|
||||
},
|
||||
layout: panelLayout,
|
||||
region: {
|
||||
height: createMemo(() => {
|
||||
if (!sideRegionOpen()) return "0px"
|
||||
if (sideTerminalOpen()) return stackedReviewHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
open: sideRegionOpen,
|
||||
},
|
||||
terminal: {
|
||||
contentHeight: createMemo(() => (sideRegionOpen() ? terminalPaneHeight() : fullSideHeight())),
|
||||
height: createMemo(() => {
|
||||
if (!sideTerminalOpen()) return "0px"
|
||||
if (sideRegionOpen()) return terminalPaneHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
},
|
||||
},
|
||||
side: { layout: panelLayout },
|
||||
size,
|
||||
terminal: {
|
||||
bottom: bottomTerminal,
|
||||
bottomOpen: bottomTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
|
||||
open: terminalOpen,
|
||||
side: sideTerminal,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -9,7 +8,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { TerminalPanel } from "@/session/terminal/panel"
|
||||
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
@@ -18,7 +17,6 @@ import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
@@ -29,42 +27,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const screen = createSessionScreenLayout(session, serverSDK.scope)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
bottomTerminalCached: false,
|
||||
sideHeightMotion: false,
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
sideTerminalPresent: false,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
bottomTerminal?: HTMLDivElement
|
||||
}>({})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
})
|
||||
const bottomTerminalPresence = createPresence({
|
||||
show: bottomTerminalVisible,
|
||||
element: () => elements.bottomTerminal ?? null,
|
||||
})
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
if (!sideVisible()) setStore("sideHeightMotion", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!isDesktop() || screen.terminal.bottom()) setStore("sideTerminalPresent", false)
|
||||
if (isDesktop() && screen.terminal.side()) setStore("bottomTerminalCached", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (screen.side.region.open()) setStore("sideRegionPresent", true)
|
||||
if (screen.review.panelOpen()) setStore("sideReviewPresent", true)
|
||||
})
|
||||
const [store, setStore] = createStore({ deferRender: false })
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = session.identity.sessionKey()
|
||||
@@ -95,24 +58,16 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
{timeline.resource() ?? ""}
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact />
|
||||
</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
{(error) => {
|
||||
throw error()
|
||||
}}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={session.identity.params.id && review.mobile.changes()}>
|
||||
<SessionMobileReview review={review} />
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
@@ -162,17 +117,16 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active(),
|
||||
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
@@ -202,125 +156,51 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={sidePresence.present() || store.sideTerminalPresent}>
|
||||
<div
|
||||
ref={(element) => setElements("side", element)}
|
||||
data-slot="session-side-panel-presence"
|
||||
data-opened={sideVisible()}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
setStore("sideHeightMotion", true)
|
||||
}}
|
||||
classList={{
|
||||
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sidePresence.present(),
|
||||
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
|
||||
!sidePresence.present(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-panel-content"
|
||||
class="absolute inset-y-0 start-0 h-full"
|
||||
style={{ width: screen.side.contentWidth() }}
|
||||
>
|
||||
<Show when={isDesktop() && screen.side.layout().visible}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={screen.review.panelOpen() || screen.files.open()}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionDesktopReview review={review} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.terminal.open() && !screen.terminal.bottomOpen()}>
|
||||
<div
|
||||
data-slot="session-side-region"
|
||||
classList={{
|
||||
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
"min-h-0 shrink-0": screen.side.layout().stacked,
|
||||
"min-h-0 flex-1": !screen.side.layout().stacked,
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
data-opened={screen.side.region.open()}
|
||||
class="absolute inset-0"
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "side-region-presence-out") return
|
||||
if (screen.side.region.open()) return
|
||||
if (sideTerminalVisible()) return
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={screen.side.layout().stacked} />
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex flex-col">
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"delay-0": !screen.side.gap.closing(),
|
||||
"delay-[200ms]": screen.side.gap.closing(),
|
||||
}}
|
||||
style={{ height: screen.side.gap.height() }}
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-side-terminal-region"
|
||||
classList={{
|
||||
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.terminal.height() }}
|
||||
>
|
||||
<Show when={store.sideTerminalPresent}>
|
||||
<div
|
||||
data-slot="side-terminal-panel-presence"
|
||||
data-opened={sideTerminalVisible()}
|
||||
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
|
||||
<TerminalPanel
|
||||
fill
|
||||
framed={false}
|
||||
present={store.sideTerminalPresent}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
data-opened={bottomTerminalVisible()}
|
||||
classList={{
|
||||
hidden: !bottomTerminalPresence.present(),
|
||||
"relative min-h-0 shrink-0": isDesktop(),
|
||||
}}
|
||||
>
|
||||
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
|
||||
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
|
||||
<Show when={isDesktop()}>
|
||||
<div class="absolute z-10 -top-1 left-0 right-0 h-2" onPointerDown={() => screen.size.start()}>
|
||||
<div
|
||||
class="absolute z-10 -top-1 left-0 right-0 h-2"
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
@@ -336,10 +216,10 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
|
||||
<TerminalPanel stacked={isDesktop()} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function looksLikeSessionID(value: string) {
|
||||
return value.length > 20 && value.startsWith("ses_")
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { sessionTitle } from "./title"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
data-session-title
|
||||
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const navigate = useNavigate()
|
||||
const tab = createMemo(() =>
|
||||
tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === server.key &&
|
||||
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
|
||||
),
|
||||
)
|
||||
const info = createMemo(() => {
|
||||
const current = tab()
|
||||
return current ? tabs.info[tabKey(current)] : undefined
|
||||
})
|
||||
const parentID = createMemo(() => {
|
||||
if (props.session?.parentID) return props.session.parentID
|
||||
const current = tab()
|
||||
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
|
||||
return current.routeParentId ?? current.sessionId
|
||||
})
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? server.ctx.data.session.get(id) : undefined
|
||||
})
|
||||
const parentTitle = createMemo(() => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
|
||||
})
|
||||
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
|
||||
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
if (!id || current?.type !== "session") return
|
||||
tabs.rememberSessionRoute(current, id, parent()?.parentID)
|
||||
navigate(sessionHref(server.key, id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={title() || parentTitle() || showProjectIcon()}>
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
dir="auto"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{value()}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentTitle() && title()}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -208,6 +208,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
parentID: () => props.child?.parentID,
|
||||
child: () => !!props.child,
|
||||
showComposer: () => true,
|
||||
handoffPrompt: () => undefined,
|
||||
promptReady: () => true,
|
||||
} satisfies SessionComposerRegionViewController
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Terminal } from "@/session/terminal/terminal"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
|
||||
import { useTerminal } from "@/session/terminal/context"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { terminalTabLabel } from "@/session/terminal/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/session/helpers"
|
||||
@@ -26,20 +26,7 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { TerminalSurface } from "./surface"
|
||||
|
||||
const MAX_CACHED_TERMINAL_WORKSPACES = 20
|
||||
|
||||
type TerminalBinding = ReturnType<ReturnType<typeof useTerminal>["bind"]>
|
||||
type CachedTerminalSurface = {
|
||||
key: string
|
||||
workspace: string
|
||||
pty: LocalPTY
|
||||
ops: TerminalBinding
|
||||
focus: boolean
|
||||
}
|
||||
|
||||
export function TerminalPanel(
|
||||
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
|
||||
) {
|
||||
export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -58,26 +45,18 @@ export function TerminalPanel(
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: undefined as string | undefined,
|
||||
autoCreated: false,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
surfaces: [] as CachedTerminalSurface[],
|
||||
workspaces: [] as string[],
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
const stacked = createMemo(() => isDesktop() && !!props.stacked)
|
||||
const panelHeight = createMemo(() => {
|
||||
if (props.fill) return "100%"
|
||||
if (!opened()) return "0px"
|
||||
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
|
||||
return `${pane()}px`
|
||||
})
|
||||
const contentHeight = createMemo(
|
||||
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
|
||||
const panelHeight = createMemo(() =>
|
||||
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
|
||||
)
|
||||
const present = createMemo(() => opened() || !!props.present)
|
||||
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||
|
||||
onMount(() => {
|
||||
@@ -89,29 +68,24 @@ export function TerminalPanel(
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
makeEventListener(document, "focusin", (event) => {
|
||||
if (event.target instanceof Element && event.target.closest("#terminal-panel")) return
|
||||
setStore("surfaces", (surface) => surface.focus, "focus", false)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) {
|
||||
setStore("autoCreated", undefined)
|
||||
setStore("autoCreated", false)
|
||||
return
|
||||
}
|
||||
|
||||
const workspace = workspaceKey()
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated === workspace) return
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||
terminal.new()
|
||||
setStore("autoCreated", workspace)
|
||||
setStore("autoCreated", true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [workspaceKey(), terminal.all().length] as const,
|
||||
([workspace, count], previous) => {
|
||||
if (!previous || previous[0] !== workspace || previous[1] <= 0 || count !== 0) return
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
@@ -123,10 +97,7 @@ export function TerminalPanel(
|
||||
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||
([next, id, requested]) => {
|
||||
if (!next || !id || !requested) return
|
||||
requestAnimationFrame(() => {
|
||||
if (!opened() || terminal.active() !== id || !terminal.focusRequested(id)) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
focusTerminalById(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -165,44 +136,19 @@ export function TerminalPanel(
|
||||
|
||||
const all = terminal.all
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [workspaceKey(), terminal.ready(), terminal.active(), terminal.all()] as const,
|
||||
([workspace, ready, active, ptys]) => {
|
||||
if (!ready) return
|
||||
|
||||
const ids = new Set(ptys.map((pty) => pty.id))
|
||||
const surfaces = store.surfaces.filter((surface) => surface.workspace !== workspace || ids.has(surface.pty.id))
|
||||
const pty = ptys.find((item) => item.id === active)
|
||||
const key = pty ? `${workspace}\0${pty.id}` : undefined
|
||||
if (pty && key && !surfaces.some((surface) => surface.key === key)) {
|
||||
surfaces.push({ key, workspace, pty, ops: terminal.bind(), focus: terminal.focusRequested(pty.id) })
|
||||
}
|
||||
|
||||
const workspaces = [...store.workspaces.filter((item) => item !== workspace), workspace].slice(
|
||||
-MAX_CACHED_TERMINAL_WORKSPACES,
|
||||
)
|
||||
const keep = new Set(workspaces)
|
||||
setStore({ surfaces: surfaces.filter((surface) => keep.has(surface.workspace)), workspaces })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
const index = store.surfaces.findIndex((surface) => surface.key === key)
|
||||
if (!store.surfaces[index]?.focus) return
|
||||
setStore("surfaces", index, "focus", false)
|
||||
if (!opened() || terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
terminal.consumeFocus(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
@@ -221,8 +167,6 @@ export function TerminalPanel(
|
||||
}}
|
||||
label={language.t("terminal.title")}
|
||||
opened={opened()}
|
||||
present={present()}
|
||||
framed={props.framed}
|
||||
desktop={isDesktop()}
|
||||
stacked={stacked()}
|
||||
height={panelHeight()}
|
||||
@@ -238,7 +182,7 @@ export function TerminalPanel(
|
||||
onCollapse={close}
|
||||
>
|
||||
<Show
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
@@ -324,35 +268,34 @@ export function TerminalPanel(
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${surface.pty.id}`}
|
||||
class="absolute inset-0"
|
||||
classList={{
|
||||
hidden:
|
||||
!present() || surface.workspace !== workspaceKey() || surface.pty.id !== terminal.active(),
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
pty={surface.pty}
|
||||
autoFocus={terminal.focusRequested(surface.pty.id)}
|
||||
onAutoFocus={() => {
|
||||
focusTerminalById(surface.pty.id)
|
||||
terminal.consumeFocus(surface.pty.id)
|
||||
}}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(surface.key, surface.pty.id, (terminalID) => surface.ops.trim(terminalID))
|
||||
}
|
||||
onCleanup={(terminal) => surface.ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(surface.key, surface.pty.id, (terminalID) => surface.ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={terminal.focusRequested(id)}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) =>
|
||||
ops.trim(terminalID),
|
||||
)
|
||||
}
|
||||
onCleanup={(terminal) => ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -5,8 +5,6 @@ export function TerminalSurface(
|
||||
props: ParentProps<{
|
||||
label: string
|
||||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
@@ -24,9 +22,6 @@ export function TerminalSurface(
|
||||
<aside
|
||||
ref={props.ref}
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
@@ -34,12 +29,13 @@ export function TerminalSurface(
|
||||
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"w-full": !props.desktop || props.stacked,
|
||||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
|
||||
"min-w-0 h-full flex-1": props.desktop && props.opened && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !props.opened,
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop,
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!props.desktop && !props.resizing,
|
||||
}}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
style={{ height: props.height }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
|
||||
<ResizeHandle
|
||||
@@ -54,8 +50,7 @@ export function TerminalSurface(
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop,
|
||||
"pointer-events-none": !props.opened,
|
||||
|
||||
@@ -221,14 +221,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let drop: VoidFunction | undefined
|
||||
let reconn: ReturnType<typeof setTimeout> | undefined
|
||||
let tries = 0
|
||||
let revealed = false
|
||||
|
||||
const reveal = () => {
|
||||
if (revealed) return
|
||||
if (!serializeAddon.serializeAsText({ trimWhitespace: true })) return
|
||||
revealed = true
|
||||
container.style.opacity = "1"
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -347,9 +339,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
|
||||
focus()
|
||||
setTimeout(focus, 0)
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
@@ -508,7 +500,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
if (restore && restoreSize) {
|
||||
await write(restore)
|
||||
reveal()
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
@@ -518,7 +509,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
await write(restore)
|
||||
reveal()
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
}
|
||||
startResize()
|
||||
@@ -615,7 +605,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
output?.push(data)
|
||||
if (!revealed) output?.flush(reveal)
|
||||
cursor += data.length
|
||||
seek = cursor
|
||||
}
|
||||
@@ -696,7 +685,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
dir="ltr"
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background, "caret-color": "transparent", opacity: 0 }}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
classList={{
|
||||
...local.classList,
|
||||
"select-text": true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user