mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 14:36:18 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee61be65b | ||
|
|
5d7ddb898b | ||
|
|
6dabba2867 | ||
|
|
f963d124c1 | ||
|
|
8f38d16246 | ||
|
|
376e950e74 | ||
|
|
b1445cf912 | ||
|
|
6e6fadae5e | ||
|
|
9c34279187 | ||
|
|
355f60ee89 | ||
|
|
0fcf9fb641 | ||
|
|
b3de11a73a | ||
|
|
439aaa61a1 | ||
|
|
52a8a1992b | ||
|
|
5d32de98d3 | ||
|
|
2db2908440 | ||
|
|
2ba1f4e05c | ||
|
|
b4d1189696 | ||
|
|
e18a61b92c | ||
|
|
3ba3bed4a1 | ||
|
|
05f5563672 | ||
|
|
ff7b91a281 | ||
|
|
16acdd091d | ||
|
|
3fd079abe7 | ||
|
|
0956864218 | ||
|
|
b1214d188c | ||
|
|
e05a15c8d3 | ||
|
|
20d900b6d5 | ||
|
|
fb30443fb7 | ||
|
|
9390e9a3b8 | ||
|
|
4609624af7 | ||
|
|
d51a5e40ec | ||
|
|
981b96320e | ||
|
|
96be289244 | ||
|
|
fe5895e57d |
No files matched your search
@@ -18,20 +18,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
# A pull request checks out its merge into the current base; the first parent is that base.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run checks
|
||||
run: bun run check
|
||||
|
||||
# Every GUI package file (app, desktop, gui-extensions, ui, session-ui) a pull request adds or edits must be free of
|
||||
# oxlint problems, warn-level rules (anti-slop) included. Other packages are not affected.
|
||||
- name: Lint changed files
|
||||
if: github.event_name == 'pull_request'
|
||||
# Against the merge's first parent, so base-branch commits the pull request has not merged are not counted as its
|
||||
# changes (the event's base SHA can predate them).
|
||||
run: bun run lint:changed HEAD^1
|
||||
@@ -2,9 +2,9 @@ name: nix-eval
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: publish-browser-extension
|
||||
|
||||
# Releases OpenCode Browser (packages/browser-extension): publishes the opencode-browser-cli helper to npm and
|
||||
# attaches the Chrome Web Store zip to a GitHub release. The version is packages/browser-extension/cli/package.json;
|
||||
# push a matching tag (browser-extension-v<version>) or run it by hand to rebuild the artifacts.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish to npm and create the GitHub release (otherwise build only)"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- "browser-extension-v*.*.*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# npm trusted publishing (OIDC) and provenance.
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
version=$(node -p "require('./packages/browser-extension/cli/package.json').version")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if [ "$REF_TYPE" = "tag" ] && [ "$REF_NAME" != "browser-extension-v$version" ]; then
|
||||
echo "::error::Tag $REF_NAME does not match cli/package.json version $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Typecheck
|
||||
run: bun typecheck
|
||||
working-directory: packages/browser-extension
|
||||
|
||||
- name: Build the helper and the store zip
|
||||
run: |
|
||||
bun run build
|
||||
cd .. && bun run package
|
||||
working-directory: packages/browser-extension/cli
|
||||
|
||||
- name: Smoke test the helper
|
||||
run: node dist/cli.mjs | grep -q "npx opencode-browser-cli install"
|
||||
working-directory: packages/browser-extension/cli
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-browser-${{ steps.version.outputs.version }}
|
||||
path: packages/browser-extension/release/opencode-browser-${{ steps.version.outputs.version }}.zip
|
||||
if-no-files-found: error
|
||||
|
||||
# Trusted publishing needs npm 11.5.1 or newer.
|
||||
- name: Update npm
|
||||
if: github.ref_type == 'tag' || inputs.publish
|
||||
run: npm install -g npm@^11.5.1
|
||||
|
||||
- name: Publish to npm
|
||||
if: github.ref_type == 'tag' || inputs.publish
|
||||
working-directory: packages/browser-extension/cli
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
# Unset when the package uses npm trusted publishing for this workflow.
|
||||
NODE_AUTH_TOKEN: ${{ secrets.OPENCODE_BROWSER_NPM_TOKEN }}
|
||||
run: |
|
||||
if npm view "opencode-browser-cli@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "opencode-browser-cli@$VERSION is already published"
|
||||
exit 0
|
||||
fi
|
||||
# dist/ and extension/ are already built; skip prepublishOnly's rebuild.
|
||||
npm publish --access public --provenance --ignore-scripts
|
||||
|
||||
- name: GitHub release
|
||||
if: github.ref_type == 'tag' || inputs.publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
tag="browser-extension-v$VERSION"
|
||||
zip="packages/browser-extension/release/opencode-browser-$VERSION.zip"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release upload "$tag" "$zip" --clobber
|
||||
else
|
||||
gh release create "$tag" "$zip" --target "$GITHUB_SHA" --title "OpenCode Browser $VERSION" --prerelease \
|
||||
--notes "Helper: \`npx opencode-browser-cli@$VERSION install\`. Upload the zip in the Chrome Web Store dashboard."
|
||||
fi
|
||||
@@ -252,13 +252,6 @@ jobs:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run app component tests
|
||||
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
|
||||
run: bun --cwd packages/app test:components
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -271,5 +264,3 @@ jobs:
|
||||
packages/app/e2e/playwright-report
|
||||
packages/session-ui/component-tests/test-results
|
||||
packages/session-ui/component-tests/playwright-report
|
||||
packages/app/component-tests/test-results
|
||||
packages/app/component-tests/playwright-report
|
||||
@@ -63,60 +63,8 @@
|
||||
"anti-slop-effect/prefer-effect-match": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/gui-extensions/src/*.ts", "packages/gui-extensions/src/*.tsx"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "solid-js",
|
||||
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
|
||||
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/gui-extensions/src/*/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "solid-js",
|
||||
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
|
||||
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "^@opencode/(app|desktop)(/|$)",
|
||||
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
|
||||
},
|
||||
{
|
||||
"regex": "^@/",
|
||||
"message": "GUI extensions never import app internals. Use the SDK."
|
||||
},
|
||||
{
|
||||
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
|
||||
"message": "Import another extension only through its contract.ts."
|
||||
},
|
||||
{
|
||||
"regex": "\\.css$",
|
||||
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/gui-extensions/src/sdk/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
@@ -110,6 +110,31 @@
|
||||
"vite-plugin-solid": "2.11.14",
|
||||
},
|
||||
},
|
||||
"packages/browser-extension": {
|
||||
"name": "@opencode/browser-extension",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/chrome": "0.3.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.2",
|
||||
"vite-plugin-solid": "2.11.14",
|
||||
},
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
@@ -122,7 +147,7 @@
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/pty": "0.2.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -355,7 +380,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.2.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
@@ -365,7 +390,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"bun-pty": "0.4.9",
|
||||
"bun-pty": "0.4.8",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -2167,19 +2192,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.2.0", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.2.0", "@opencode-ai/pty-darwin-x64": "0.2.0", "@opencode-ai/pty-linux-arm64-gnu": "0.2.0", "@opencode-ai/pty-linux-arm64-musl": "0.2.0", "@opencode-ai/pty-linux-x64-gnu": "0.2.0", "@opencode-ai/pty-linux-x64-musl": "0.2.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-pV86urAwinpwFXX8AJlOf8S9CVJhGsV+11I/J6TcxMW0c7u6LgeODzdj/BVBC6jUhsG2aKDxg8rACMcDCy3HiA=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TPVGCQk5E65IY3ipcpd17rwKehdvXcXEYRhhGzok/6Dske69ei2S+ERz7NXZ8cKFAvbiiT771HHM+XyVYAl+7A=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iH6/liY7xN1OXVD9eGzdH11BVGvnpPsH5Z1Unz0Pn9EzkPFf28oDNKNyeXV0xIAP53oiYp8gpRq2PADNwbVsTg=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GhdrmbUzxGHRfWvt1qutgVD4HLb7aSBsgWGjFsc6k8HjO5ZbdaC3ScRyd512YAAG5KAROLKZ6+0SEBsm5xFYlA=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EbHchDsMmL5aOReIoo8NvQkKnyhyKmoL2RleF2JYF76va3FKuksmwsOBgQJh54i+QA4Fd0+9x54Q+2fJOzPiRQ=="],
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
@@ -2189,6 +2214,8 @@
|
||||
|
||||
"@opencode/app": ["@opencode/app@workspace:packages/app"],
|
||||
|
||||
"@opencode/browser-extension": ["@opencode/browser-extension@workspace:packages/browser-extension"],
|
||||
|
||||
"@opencode/cli": ["@opencode/cli@workspace:packages/cli"],
|
||||
|
||||
"@opencode/client": ["@opencode/client@workspace:packages/client"],
|
||||
@@ -3111,6 +3138,8 @@
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/chrome": ["@types/chrome@0.3.4", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-dcySM5R3WAUVYFjylu6tvI4D03hPKjgAqk+tKZ/tm/vxL4mLgbnSsOEQ3J7V99qP/U3Oo9IORstJLmX2hPaDlA=="],
|
||||
|
||||
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
|
||||
|
||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||
@@ -3183,12 +3212,18 @@
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
|
||||
|
||||
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
|
||||
|
||||
"@types/fontkit": ["@types/fontkit@2.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw=="],
|
||||
|
||||
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
||||
@@ -3549,7 +3584,7 @@
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
@@ -6221,6 +6256,8 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode/browser-extension/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"@opencode/cli/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode/cli/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
|
||||
|
||||
@@ -40,8 +40,6 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
copyDesktopItems
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
darwin.cctools
|
||||
darwin.sigtool
|
||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||
darwin.autoSignDarwinBinariesHook
|
||||
];
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2RlbJRTEKSliuUbAE2lAktX63JFR/RKAuLkcCou8wb4=",
|
||||
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
|
||||
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
|
||||
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
|
||||
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
|
||||
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -18,8 +18,7 @@
|
||||
"dev:www": "bun run --cwd services/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml && bun script/sdk-docs.ts",
|
||||
"lint:changed": "bun script/lint-changed.ts",
|
||||
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
|
||||
@@ -27,31 +27,6 @@ Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`
|
||||
`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
|
||||
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
### Google Interactions
|
||||
|
||||
`Google.configure({ apiKey }).interactions(modelID)` selects the Interactions API; `.model(modelID)` still selects
|
||||
GenerateContent. The package entrypoint is `@opencode/ai/providers/google/interactions`.
|
||||
|
||||
```ts
|
||||
const model = Google.configure({ apiKey }).interactions("gemini-3.8-flash")
|
||||
const response = yield* LLM.generate({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", store: true },
|
||||
})
|
||||
```
|
||||
|
||||
Interactions supports text output, streamed function calls, native tool results, thought signatures, and multimodal
|
||||
input. Full-history replay is the default (`store: false`); implicit caching works without retained interactions.
|
||||
For server-side continuation, set `store: true` on the predecessor, read `interactionId` from the final event's
|
||||
`providerMetadata.google`, and pass `previousInteractionId` on the next request with **only new messages**. Repeat
|
||||
the system instructions and tool declarations on each request. Set `store: true` on each response you intend to
|
||||
continue from. The package does not automatically select or persist continuation IDs.
|
||||
|
||||
Raw usage is preserved in `usage.providerMetadata.google`. `inputTokens` follows Google's top-level accounting;
|
||||
`contextTokens` uses its full `raw_prompt_token` count when supplied. These can differ substantially with server-side
|
||||
continuation. Explicit caches, hosted tools, and generated media are not supported by this initial protocol.
|
||||
|
||||
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
|
||||
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
@@ -25,6 +25,8 @@ const WebExtractorItem = Schema.StructWithRest(
|
||||
)
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, WebExtractorItem])),
|
||||
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
|
||||
enable_thinking: Options.fields.enableThinking,
|
||||
previous_response_id: Options.fields.previousResponseId,
|
||||
conversation: Options.fields.conversation,
|
||||
@@ -50,7 +52,7 @@ export const protocol = Protocol.make({
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(req, adapter)
|
||||
const choice = body.tool_choice
|
||||
return {
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
...body,
|
||||
enable_thinking: opts.enableThinking,
|
||||
previous_response_id: opts.previousResponseId,
|
||||
@@ -60,7 +62,7 @@ export const protocol = Protocol.make({
|
||||
typeof choice === "object" && choice.type === "function"
|
||||
? { type: "allowed_tools" as const, mode: "required" as const, tools: [choice] }
|
||||
: choice,
|
||||
}
|
||||
})
|
||||
}),
|
||||
},
|
||||
stream: {
|
||||
|
||||
@@ -124,15 +124,7 @@ const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMR
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const messages: (typeof Message.Type)[] = request.system.length
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
request.system.length === 1
|
||||
? request.system[0].text
|
||||
: request.system.map((part) => ({ type: "text", text: part.text })),
|
||||
},
|
||||
]
|
||||
? [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
: []
|
||||
for (const message of flattened.request.messages) {
|
||||
if (message.role === "system") {
|
||||
|
||||
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,561 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderID,
|
||||
Usage,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { Media } from "../media.js"
|
||||
import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js"
|
||||
import { encodeJson } from "../utils/json.js"
|
||||
import { JsonObject, knownString, lenient, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "google-interactions"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// =============================================================================
|
||||
// Public Model Input
|
||||
// =============================================================================
|
||||
const ThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const Options = Schema.Struct({
|
||||
previousInteractionId: lenient(Schema.String),
|
||||
store: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(ThinkingLevel),
|
||||
thinkingSummaries: lenient(knownString<"auto" | "none">()),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
const Text = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })
|
||||
const MediaContent = Schema.Struct({
|
||||
type: Schema.Literals(["image", "audio", "video", "document"]),
|
||||
data: Schema.optional(Schema.String),
|
||||
uri: Schema.optional(Schema.String),
|
||||
mime_type: Schema.String,
|
||||
})
|
||||
const Content = Schema.Union([Text, MediaContent])
|
||||
const InputStep = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literals(["user_input", "model_output"]), content: Schema.Array(Content) }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("thought"),
|
||||
signature: Schema.optional(Schema.String),
|
||||
summary: Schema.optional(Schema.Array(Text)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("function_call"),
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
arguments: Schema.Unknown,
|
||||
signature: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("function_result"),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
result: Schema.Unknown,
|
||||
is_error: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
])
|
||||
type InputStep = typeof InputStep.Type
|
||||
const ToolChoice = Schema.Union([
|
||||
Schema.Literals(["auto", "any", "none"]),
|
||||
Schema.Struct({ allowed_tools: Schema.Struct({ mode: Schema.Literal("any"), tools: Schema.Array(Schema.String) }) }),
|
||||
])
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(InputStep),
|
||||
stream: Schema.Literal(true),
|
||||
store: Schema.Boolean,
|
||||
previous_interaction_id: Schema.optional(Schema.String),
|
||||
system_instruction: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
tools: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("function"),
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
}),
|
||||
),
|
||||
),
|
||||
generation_config: Schema.Struct({
|
||||
max_output_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stop_sequences: Schema.optional(Schema.Array(Schema.String)),
|
||||
thinking_level: Schema.optional(ThinkingLevel),
|
||||
thinking_summaries: Schema.optional(Schema.String),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
}),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Streaming Event Schema
|
||||
// =============================================================================
|
||||
const RawUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
total_input_tokens: optionalNull(Schema.Number),
|
||||
total_cached_tokens: optionalNull(Schema.Number),
|
||||
total_output_tokens: optionalNull(Schema.Number),
|
||||
total_thought_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
raw_prompt_token: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type RawUsage = typeof RawUsage.Type
|
||||
const OutputStep = Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(JsonObject),
|
||||
signature: Schema.optional(Schema.String),
|
||||
summary: Schema.optional(Schema.Array(Text)),
|
||||
content: Schema.optional(Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) }))),
|
||||
})
|
||||
type OutputStep = typeof OutputStep.Type
|
||||
const Delta = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("arguments_delta"), arguments: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("thought_signature"), signature: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("thought_summary"), content: Text }),
|
||||
// Unknown output modalities must fail explicitly rather than disappearing from a successful response.
|
||||
Schema.Struct({ type: Schema.String }),
|
||||
])
|
||||
const Interaction = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.String,
|
||||
usage: Schema.optional(RawUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Event = Schema.Union([
|
||||
Schema.Struct({ event_type: Schema.Literal("step.start"), index: Schema.Number, step: OutputStep }),
|
||||
Schema.Struct({ event_type: Schema.Literal("step.delta"), index: Schema.Number, delta: Delta }),
|
||||
Schema.Struct({ event_type: Schema.Literal("step.stop"), index: Schema.Number }),
|
||||
Schema.Struct({
|
||||
event_type: Schema.Literal("interaction.created"),
|
||||
interaction: Schema.Struct({ id: Schema.optional(Schema.String) }),
|
||||
}),
|
||||
Schema.Struct({
|
||||
event_type: Schema.Literal("interaction.status_update"),
|
||||
interaction_id: Schema.optional(Schema.String),
|
||||
status: Schema.String,
|
||||
}),
|
||||
Schema.Struct({ event_type: Schema.Literal("interaction.completed"), interaction: Interaction }),
|
||||
Schema.Struct({ event_type: Schema.Literal("error"), error: Schema.Unknown }),
|
||||
])
|
||||
type Event = typeof Event.Type
|
||||
|
||||
// =============================================================================
|
||||
// Parser State
|
||||
// =============================================================================
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly metadataKey: string
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly steps: Partial<Record<number, OutputStep>>
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly completed: boolean
|
||||
}
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Construction
|
||||
// =============================================================================
|
||||
const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) {
|
||||
if (
|
||||
asset.kind !== "image" &&
|
||||
asset.kind !== "audio" &&
|
||||
asset.kind !== "video" &&
|
||||
asset.mediaType !== "application/pdf" &&
|
||||
asset.mediaType !== "text/csv"
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`Google Interactions does not support ${asset.mediaType} document input`,
|
||||
)
|
||||
const type: (typeof MediaContent.Type)["type"] =
|
||||
asset.kind === "image" || asset.kind === "audio" || asset.kind === "video" ? asset.kind : "document"
|
||||
const uri = MediaInput.refID(asset, ProviderID.make("google"))
|
||||
if (uri !== undefined) return { type, uri, mime_type: asset.mediaType }
|
||||
const inline = yield* ProviderShared.requireInlineMedia("Google Interactions", asset)
|
||||
return { type, data: inline.base64, mime_type: inline.mime }
|
||||
})
|
||||
|
||||
const signature = (metadata: ProviderMetadata | undefined, key: string) => {
|
||||
const value = metadata?.[key]
|
||||
return ProviderShared.isRecord(value) && typeof value.interactionSignature === "string"
|
||||
? value.interactionSignature
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) {
|
||||
const steps: InputStep[] = []
|
||||
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Google Interactions", message)
|
||||
steps.push({ type: "user_input", content: [{ type: "text", text: part.text }] })
|
||||
continue
|
||||
}
|
||||
const start = steps.length
|
||||
// Consecutive ordinary content remains one native message; tools and thoughts retain their chronology.
|
||||
const append = (content: typeof Content.Type) => {
|
||||
const type = message.role === "assistant" ? "model_output" : "user_input"
|
||||
const last = steps.at(-1)
|
||||
if (steps.length > start && last?.type === type)
|
||||
steps[steps.length - 1] = { type, content: [...last.content, content] }
|
||||
else steps.push({ type, content: [content] })
|
||||
}
|
||||
for (const part of message.content) {
|
||||
if (message.role === "tool") {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* ProviderShared.unsupportedContent(ADAPTER, "tool", ["tool-result"])
|
||||
steps.push({
|
||||
type: "function_result",
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
result: yield* lowerToolResult(part),
|
||||
is_error: part.result.type === "error" || undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
append({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
append(yield* mediaContent(part.media))
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && part.type === "reasoning") {
|
||||
steps.push({
|
||||
type: "thought",
|
||||
signature: signature(part.providerMetadata, key),
|
||||
summary: part.text ? [{ type: "text", text: part.text }] : undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && part.type === "tool-call") {
|
||||
steps.push({
|
||||
type: "function_call",
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
arguments: part.input,
|
||||
signature: signature(part.providerMetadata, key),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(
|
||||
ADAPTER,
|
||||
message.role,
|
||||
message.role === "user" ? ["text", "media"] : ["text", "media", "reasoning", "tool-call"],
|
||||
)
|
||||
}
|
||||
}
|
||||
return steps
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
|
||||
return yield* Effect.forEach(part.result.value, (item): Effect.Effect<typeof Content.Type, AIError> => {
|
||||
if (item.type === "text") return Effect.succeed({ type: "text", text: item.text })
|
||||
return mediaContent(ProviderShared.toolFileMedia(item).media)
|
||||
})
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("GoogleInteractions.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
if (flattened.tools.some((tool) => tool.native !== undefined))
|
||||
return yield* ProviderShared.invalidRequest("Google Interactions hosted tools are not supported")
|
||||
if (
|
||||
request.generation?.topK !== undefined ||
|
||||
request.generation?.frequencyPenalty !== undefined ||
|
||||
request.generation?.presencePenalty !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Google Interactions does not support topK, frequencyPenalty, or presencePenalty",
|
||||
)
|
||||
const choice =
|
||||
request.toolChoice === undefined
|
||||
? undefined
|
||||
: yield* ProviderShared.matchToolChoice(ADAPTER, request.toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "any" as const,
|
||||
tool: (name) => ({ allowed_tools: { mode: "any" as const, tools: [name] } }),
|
||||
})
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(flattened.request),
|
||||
stream: true as const,
|
||||
// Full-history replay need not create retained provider resources. Continuation callers opt in to storage.
|
||||
store: options.store ?? false,
|
||||
previous_interaction_id: options.previousInteractionId,
|
||||
system_instruction: request.system.length ? ProviderShared.joinText(request.system) : undefined,
|
||||
service_tier: options.serviceTier,
|
||||
tools: flattened.tools.length
|
||||
? flattened.tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
}))
|
||||
: undefined,
|
||||
generation_config: {
|
||||
max_output_tokens: request.generation?.maxTokens,
|
||||
temperature: request.generation?.temperature,
|
||||
top_p: request.generation?.topP,
|
||||
seed: request.generation?.seed,
|
||||
stop_sequences: request.generation?.stop,
|
||||
thinking_level: options.thinkingLevel,
|
||||
thinking_summaries: options.thinkingSummaries,
|
||||
tool_choice: choice,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
const metadata = (state: ParserState, step: OutputStep): ProviderMetadata => ({
|
||||
[state.metadataKey]: { interactionSignature: step.signature },
|
||||
})
|
||||
const mapUsage = (usage: RawUsage | undefined, key: string) => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.total_input_tokens ?? undefined
|
||||
const cached = usage.total_cached_tokens ?? undefined
|
||||
const reasoning = usage.total_thought_tokens ?? undefined
|
||||
const output =
|
||||
usage.total_output_tokens === undefined || usage.total_output_tokens === null
|
||||
? undefined
|
||||
: usage.total_output_tokens + (reasoning ?? 0)
|
||||
return new Usage({
|
||||
contextTokens: usage.raw_prompt_token ?? input,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: usage.total_tokens ?? undefined,
|
||||
providerMetadata: { [key]: usage },
|
||||
})
|
||||
}
|
||||
|
||||
const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
|
||||
state: ParserState,
|
||||
index: number,
|
||||
step: OutputStep,
|
||||
) {
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
let tools = state.tools
|
||||
const id = String(index)
|
||||
if (step.type === "thought") {
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, id, metadata(state, step))
|
||||
for (const part of step.summary ?? []) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, id, part.text)
|
||||
} else if (step.type === "model_output") {
|
||||
lifecycle = Lifecycle.textStart(lifecycle, events, id)
|
||||
for (const part of step.content ?? []) {
|
||||
if (part.type !== "text")
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
`Unsupported Interactions output: ${part.type}`,
|
||||
encodeJson(step),
|
||||
)
|
||||
if (part.text) lifecycle = Lifecycle.textDelta(lifecycle, events, id, part.text)
|
||||
}
|
||||
} else if (step.type === "function_call") {
|
||||
if (!step.id || !step.name)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Interactions function call lacks id or name", encodeJson(step))
|
||||
tools = ToolStream.start(tools, index, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
providerMetadata: metadata(state, step),
|
||||
input: step.arguments && Object.keys(step.arguments).length ? encodeJson(step.arguments) : "",
|
||||
})
|
||||
events.push(LLMEvent.toolInputStart({ id: step.id, name: step.name, providerMetadata: metadata(state, step) }))
|
||||
} else
|
||||
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions step: ${step.type}`, encodeJson(step))
|
||||
return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
|
||||
state: ParserState,
|
||||
index: number,
|
||||
delta: typeof Delta.Type,
|
||||
) {
|
||||
const step = state.steps[index]
|
||||
if (!step)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Interactions delta without step.start", encodeJson(delta))
|
||||
const events: LLMEvent[] = []
|
||||
if (delta.type === "text" && "text" in delta && step.type === "model_output")
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, String(index), delta.text) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
if (delta.type === "thought_summary" && "content" in delta && step.type === "thought")
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, String(index), delta.content.text) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
if (delta.type === "thought_signature" && "signature" in delta) {
|
||||
const next = { ...step, signature: delta.signature }
|
||||
const tool = state.tools[index]
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
steps: { ...state.steps, [index]: next },
|
||||
tools: tool ? { ...state.tools, [index]: { ...tool, providerMetadata: metadata(state, next) } } : state.tools,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
if (delta.type === "arguments_delta" && "arguments" in delta && step.type === "function_call") {
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
index,
|
||||
delta.arguments,
|
||||
"Interactions arguments without function call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
return [{ ...state, tools: result.tools }, result.events] satisfies StepResult
|
||||
}
|
||||
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta))
|
||||
})
|
||||
|
||||
const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) {
|
||||
const step = state.steps[index]
|
||||
if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start")
|
||||
const events: LLMEvent[] = []
|
||||
if (step.type === "thought")
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, String(index), metadata(state, step)) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
if (step.type === "model_output")
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, String(index)) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
|
||||
return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult
|
||||
})
|
||||
|
||||
const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) {
|
||||
switch (event.event_type) {
|
||||
case "step.start":
|
||||
return yield* onStart(state, event.index, event.step)
|
||||
case "step.delta":
|
||||
return yield* onDelta(state, event.index, event.delta)
|
||||
case "step.stop":
|
||||
return yield* onStop(state, event.index)
|
||||
case "interaction.created":
|
||||
case "interaction.status_update":
|
||||
return [state, []] satisfies StepResult
|
||||
case "error":
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: providerErrorMessage(encodeJson(event)) ?? "Google Interactions stream error",
|
||||
data: event.error,
|
||||
rawBody: encodeJson(event),
|
||||
}),
|
||||
})
|
||||
case "interaction.completed": {
|
||||
const interaction = event.interaction
|
||||
if (interaction.status === "failed" || interaction.status === "cancelled")
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: `Google Interactions ${interaction.status}`,
|
||||
data: interaction,
|
||||
rawBody: encodeJson(event),
|
||||
}),
|
||||
})
|
||||
if (!["completed", "requires_action", "incomplete"].includes(interaction.status))
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
`Unexpected terminal Interactions status: ${interaction.status}`,
|
||||
encodeJson(event),
|
||||
)
|
||||
const pending = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events = [...pending.events]
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
interaction.status === "requires_action"
|
||||
? "tool-calls"
|
||||
: interaction.status === "incomplete"
|
||||
? "length"
|
||||
: "stop",
|
||||
raw: interaction.status,
|
||||
},
|
||||
usage: mapUsage(interaction.usage, state.metadataKey),
|
||||
providerMetadata: { [state.metadataKey]: { interactionId: interaction.id } },
|
||||
})
|
||||
return [{ ...state, lifecycle, tools: pending.tools, completed: true }, events] satisfies StepResult
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And Route
|
||||
// =============================================================================
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
sanitizer: "gemini",
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(Event),
|
||||
initial: (request): ParserState => ({
|
||||
route: `${request.model.provider}/${ADAPTER}`,
|
||||
metadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
steps: {},
|
||||
tools: ToolStream.empty<number>(),
|
||||
completed: false,
|
||||
}),
|
||||
step,
|
||||
terminal: (event) => event.event_type === "interaction.completed",
|
||||
onHalt: (state) =>
|
||||
state.completed
|
||||
? Effect.succeed([])
|
||||
: Effect.fail(
|
||||
ProviderShared.eventError(ADAPTER, "Google Interactions stream ended before interaction.completed"),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "google",
|
||||
providerMetadataKey: "google",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/interactions", { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export * as GoogleInteractions from "./google-interactions.js"
|
||||
@@ -2,7 +2,6 @@ export * as AnthropicMessages from "./anthropic-messages.js"
|
||||
export * as BedrockConverse from "./bedrock-converse.js"
|
||||
export * as CohereChat from "./cohere-chat.js"
|
||||
export * as Gemini from "./gemini.js"
|
||||
export * as GoogleInteractions from "./google-interactions.js"
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
export * as OpenAIChat from "./openai-chat.js"
|
||||
export * as OpenAIImages from "./openai-images.js"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
@@ -43,6 +44,13 @@ const ImageItem = Schema.Struct({
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, ImageItem])),
|
||||
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const MessageAnnotations = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ annotations: optionalArray(JsonObject) })),
|
||||
})
|
||||
@@ -54,13 +62,12 @@ interface ParserState extends OpenResponses.ParserState {
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
nativeTool: (native) => ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(native.meta),
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(ImageItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
return yield* OpenResponses.fromRequestWithAdapter(
|
||||
const projected = ProviderShared.flattenToolRequest(
|
||||
LLMRequest.update(request, {
|
||||
messages: request.messages.map((message) =>
|
||||
Message.make({
|
||||
@@ -86,8 +93,23 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
|
||||
}),
|
||||
),
|
||||
}),
|
||||
adapter,
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
...(yield* OpenResponses.lowerConversation(projected.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
tools:
|
||||
projected.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
Effect.gen(function* () {
|
||||
if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
|
||||
}),
|
||||
),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* OpenResponses.lowerToolChoice(NAME, request.toolChoice) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
@@ -200,7 +222,7 @@ const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, inpu
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: { schema: OpenResponses.OpenResponsesBody, from: fromRequest },
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request): ParserState => ({ ...OpenResponses.initial(request, adapter), completedItems: new Set() }),
|
||||
@@ -209,6 +231,6 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const httpTransport = OpenResponses.httpTransport
|
||||
export const httpTransport = HttpTransport.sseJson.with<Schema.Schema.Type<typeof Body>>()
|
||||
|
||||
export * as MetaResponses from "./meta-responses.js"
|
||||
@@ -68,10 +68,7 @@ const MistralAssistantToolCall = Schema.Struct({
|
||||
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
|
||||
|
||||
const MistralMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
@@ -338,17 +335,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
request.system.length === 1
|
||||
? request.system[0].text
|
||||
: request.system.map((part) => ({ type: "text", text: part.text })),
|
||||
},
|
||||
]
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
|
||||
|
||||
@@ -168,20 +168,10 @@ export const ConfigurationUpdate = Schema.Struct({
|
||||
type: Schema.Literal("configuration_update"),
|
||||
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
|
||||
})
|
||||
export type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
|
||||
|
||||
export const HostedToolReplay = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.String,
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
export type HostedToolReplayItem = Schema.Schema.Type<typeof HostedToolReplay>
|
||||
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
ConfigurationUpdate,
|
||||
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
@@ -214,9 +204,24 @@ export const InputItem = Schema.Union([
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
HostedToolItem,
|
||||
HostedToolReplay,
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
export type HostedToolReplayItem = {
|
||||
readonly type: string
|
||||
readonly id: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| HostedToolReplayItem
|
||||
| ConfigurationUpdate
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
@@ -234,14 +239,6 @@ export const Tool = Schema.Struct({
|
||||
strict: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
export const HostedTool = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
export type HostedTool = Schema.Schema.Type<typeof HostedTool>
|
||||
|
||||
export const ToolChoice = Schema.Union([
|
||||
Schema.Literals(["auto", "none", "required"]),
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
@@ -260,7 +257,7 @@ export const coreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(InputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(Schema.Union([Tool, HostedTool])),
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
@@ -295,7 +292,7 @@ export const coreFields = {
|
||||
frequency_penalty: Schema.optional(Schema.Number),
|
||||
}
|
||||
|
||||
export const OpenResponsesBody = Schema.Struct({
|
||||
const OpenResponsesBody = Schema.Struct({
|
||||
...coreFields,
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
@@ -400,7 +397,9 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly nativeTool?: (native: NonNullable<ToolDefinition["native"]>) => Effect.Effect<HostedTool, AIError>
|
||||
readonly nativeTool?: (
|
||||
native: NonNullable<ToolDefinition["native"]>,
|
||||
) => Effect.Effect<{ readonly type: string }, AIError>
|
||||
readonly lowerMedia?: (input: {
|
||||
readonly part: MediaPart
|
||||
readonly media: Media.Inline | undefined
|
||||
@@ -456,10 +455,8 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protoco
|
||||
})
|
||||
|
||||
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
|
||||
Effect.forEach(
|
||||
tools,
|
||||
(tool): Effect.Effect<Schema.Schema.Type<typeof Tool> | HostedTool, AIError> =>
|
||||
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
|
||||
Effect.forEach(tools, (tool) =>
|
||||
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
|
||||
)
|
||||
|
||||
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -595,7 +592,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const input: OpenResponsesInputItem[] = []
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = metadataKey(request.model)
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -830,7 +827,11 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
}
|
||||
})
|
||||
|
||||
export const fromRequest = (request: LLMRequest) => fromRequestWithAdapter(request, BASE_ADAPTER)
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type LLMRequest,
|
||||
@@ -45,6 +46,12 @@ const OpenAIChatCacheControl = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
})
|
||||
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: Schema.Struct({
|
||||
|
||||
@@ -103,8 +103,15 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
OpenResponses.InputItem,
|
||||
OpenAIResponsesHostedToolItem,
|
||||
OpenResponses.ConfigurationUpdate,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
@@ -127,7 +134,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, CompactionTrigger])),
|
||||
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
@@ -195,13 +202,15 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
: { type: "function" as const, name },
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const updates = resolveEffortUpdates(request, options.reasoningEffort)
|
||||
return {
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
@@ -211,7 +220,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
? undefined
|
||||
: (OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
|
||||
@@ -31,12 +31,19 @@ const XAIResponsesHostedToolItem = Schema.Union([
|
||||
),
|
||||
])
|
||||
|
||||
const XAIResponsesBody = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
if (request.providerOptions?.contextManagement !== undefined)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
@@ -46,7 +53,7 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL
|
||||
message:
|
||||
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
})
|
||||
return yield* OpenResponses.fromRequestWithAdapter(request, adapter)
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
@@ -76,7 +83,7 @@ const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
schema: OpenResponses.OpenResponsesBody,
|
||||
schema: XAIResponsesBody,
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { MediaRoute } from "../route/media.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { GoogleInteractions } from "../protocols/google-interactions.js"
|
||||
import { GoogleImages } from "../protocols/google-images.js"
|
||||
import { GoogleSpeech } from "../protocols/google-speech.js"
|
||||
import { GoogleTranscription } from "../protocols/google-transcription.js"
|
||||
@@ -17,16 +16,15 @@ export type { GoogleTranscriptionOptions } from "../protocols/google-transcripti
|
||||
export type { GoogleVideoOptions } from "../protocols/google-video.js"
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
export type GoogleInteractionsOptionsInput = GoogleInteractions.OptionsInput
|
||||
|
||||
export const id = ProviderID.make("google")
|
||||
|
||||
export const routes = [Gemini.route, GoogleInteractions.route]
|
||||
export const routes = [Gemini.route]
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput & GoogleInteractions.ProviderOptionsInput
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -47,19 +45,12 @@ const configuredRoute = (input: Config) => {
|
||||
return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
|
||||
}
|
||||
|
||||
const interactionsRoute = (input: Config) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return GoogleInteractions.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
const media = MediaRoute.deployment(input, auth(input))
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
interactions: (modelID: string | ModelID) =>
|
||||
interactionsRoute(input).model<GoogleInteractions.ProviderOptionsInput>({ id: modelID }),
|
||||
image: (modelID: string | ModelID) => GoogleImages.model({ ...media, id: modelID }),
|
||||
video: (modelID: string | ModelID) => GoogleVideo.model({ ...media, id: modelID }),
|
||||
speech: (modelID: string | ModelID) => GoogleSpeech.model({ ...media, id: modelID }),
|
||||
@@ -82,7 +73,6 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
|
||||
}).model(modelID)
|
||||
|
||||
export const image = provider.image
|
||||
export const interactions = provider.interactions
|
||||
export const video = provider.video
|
||||
export const speech = provider.speech
|
||||
export const transcription = provider.transcription
|
||||
@@ -1,21 +0,0 @@
|
||||
import { configure } from "../google.js"
|
||||
import type { ProviderPackage } from "../../provider-package.js"
|
||||
import type { GoogleInteractions } from "../../protocols/google-interactions.js"
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
GoogleInteractions.ProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, GoogleInteractions.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).interactions(modelID)
|
||||
@@ -358,6 +358,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared> {
|
||||
const protocol = input.protocol
|
||||
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
|
||||
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
|
||||
const decodeEvent = (route: string) => (frame: Frame) =>
|
||||
decodeEventEffect(frame).pipe(
|
||||
@@ -416,7 +417,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
request,
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody: ProviderShared.encodeJson,
|
||||
encodeBody,
|
||||
middleware: options?.http,
|
||||
webSocket: options?.webSocket,
|
||||
}),
|
||||
@@ -575,7 +576,9 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options
|
||||
const resolved = prepareRequest(request)
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body.from(resolved)
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
|
||||
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages.js"
|
||||
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
import * as OpenAICompatible from "../src/providers/openai-compatible.js"
|
||||
import * as OpenRouter from "../src/providers/openrouter.js"
|
||||
import * as XAI from "../src/providers/xai.js"
|
||||
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@
|
||||
"provider:cohere"
|
||||
],
|
||||
"name": "cohere/continues-a-native-tool-call",
|
||||
"recordedAt": "2026-10-03T04:09:51.600Z"
|
||||
"recordedAt": "2026-10-02T02:36:16.143Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -17,14 +17,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"d6c0d412-926c-42ed-b152-043404f8c33c\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" available\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" specifically\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" mentioned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" as\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: conteLine truncated
|
||||
"body": "event: message-start\ndata: {\"id\":\"a57af21c-0496-4123-8e83-bcc6abb9bcae\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" about\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" access\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" say\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" use\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: conteLine truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -35,14 +35,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking for the weather in Paris. I have a get_weather function available that takes a city parameter. The user specifically mentioned \\\"Paris\\\" as the city. I need to use the get_weather tool exactly once as per the policies, and I have all the required parameters (city = \\\"Paris\\\").\\n\\nLet me call the function with the city parameter set to \\\"Paris\\\".\"}],\"tool_calls\":[{\"id\":\"get_weather_vfp5ryredwrg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_vfp5ryredwrg\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking about the weather in Paris. I have access to a get_weather function that takes a city parameter. The instructions say to use the get_weather tool exactly once, and then reply exactly \\\"Paris is sunny\\\" after getting the tool result.\\n\\nLet me call the get_weather function with \\\"Paris\\\" as the city parameter.\"}],\"tool_calls\":[{\"id\":\"get_weather_mdhvw7kbf1e3\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_mdhvw7kbf1e3\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"0c6cba0e-442d-4e28-96b2-c3cd6ff3b22d\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" returned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" data\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" (\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"presumably\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Celsius\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\")\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Condition\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n\\nI\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" should\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" present\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" information\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\Line truncated
|
||||
"body": "event: message-start\ndata: {\"id\":\"fd1ac100-b2c5-42f4-ae1c-6de65aa5ef6a\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" has\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" with\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" of\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" According\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" after\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" getting\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" need\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" reply\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: cLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-interactions",
|
||||
"provider:google",
|
||||
"protocol:google-interactions"
|
||||
],
|
||||
"name": "google-interactions/continues-tool-results-with-previous-interaction-id",
|
||||
"recordedAt": "2026-10-03T18:10:17.900Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":true,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"any\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EoACCv0BAWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_326996\",\"signature\":\"AWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"requires_action\",\"usage\":{\"total_tokens\":114,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":30,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}]},\"created\":\"2026-10-03T18:10:16Z\",\"updated\":\"2026-10-03T18:10:16Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"function_result\",\"call_id\":\"call_326996\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"previous_interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"none\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TchP60hnCGy9QgdyJKikXfeP9DBx/BVTE92JiMUcdYnAQ7vGOt2zL8v0GQMbCshndVE0+WeHt0QXyzeeMeNFH/UQ5bxo3Vs+T3H1e6Fq3LFIjDuvYvMYMRLk44oULO1EhoCg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":115,\"total_input_tokens\":99,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":99}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":134,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:17Z\",\"updated\":\"2026-10-03T18:10:17Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions/v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE",
|
||||
"headers": {},
|
||||
"body": ""
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-interactions",
|
||||
"provider:google",
|
||||
"protocol:google-interactions"
|
||||
],
|
||||
"name": "google-interactions/replays-native-tool-results-and-signatures-statelessly",
|
||||
"recordedAt": "2026-10-03T18:10:15.040Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":{\"allowed_tools\":{\"mode\":\"any\",\"tools\":[\"get_weather\"]}}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_1850980\",\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"requires_action\",\"usage\":{\"total_tokens\":84,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}]},\"created\":\"2026-10-03T18:10:14Z\",\"updated\":\"2026-10-03T18:10:14Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"thought\",\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\"},{\"type\":\"function_call\",\"id\":\"call_1850980\",\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"},\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\"},{\"type\":\"function_result\",\"call_id\":\"call_1850980\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":\"none\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TRPN/gVeX86bn3+mP2Y1jv3jX464uSnSB+RuBqyig1Xfg52RBzk5GzUt98niutpbttYfMLkXURehjrP1AJUJzQbGLMqmqKD5iEp3bgLq46B3Ov+RcO539Fcz6+qCpPx+VaMQ=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":108,\"total_input_tokens\":92,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":92}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":104,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:15Z\",\"updated\":\"2026-10-03T18:10:15Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-interactions",
|
||||
"provider:google",
|
||||
"protocol:google-interactions"
|
||||
],
|
||||
"name": "google-interactions/streams-reasoning-and-retains-thought-signatures",
|
||||
"recordedAt": "2026-10-03T18:08:37.674Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":4096,\"thinking_level\":\"high\",\"thinking_summaries\":\"auto\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Analyzing Congruences**\\n\\nI'm tackling a system of congruences to find the smallest positive integer x. Currently, I'm reviewing the Chinese Remainder Theorem as a potential solution, or considering a direct congruence-based approach. The congruences are as follows: $x \\\\equiv 1 \\\\pmod{7}$, $x \\\\equiv 2 \\\\pmod{9}$, $x \\\\equiv 3 \\\\pmod{11}$. I will use these congruences to find a solution.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Combining Congruences**\\n\\nI've made progress by combining the first two congruences. I determined that x is congruent to 29 modulo 63. Now, I am focused on the remaining congruence, attempting to solve for k in the expression 63k + 29 ≡ 3 (mod 11). I simplified the expression to 8k ≡ 7 (mod 11) and am looking to multiply both sides to eliminate the coefficient of k.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EskbCsYbAWkUfRM84T8fIy7KmpVRW3jt54Qo5USy0D9bJVmz6itgVBPMK3oD6Lx2AlpdFNnJ4oKCuDmIBs7ok77Tx10Ou1qfT0CsnJ391kx2pc1xAKdJJxvv2nBkHzVWb2/BORG0YNsdE96ulew1vC6x4mu/33XglL06m1hNhK8DPXGdn5lWMhKOqb9L9oBhMxazHUMlwStX67RdjURSVw/lodq/6Ao6PPTguHE9gEyh/iWwALZJCKkZw0LNgdWSRa/Ke4tfr8bncWcIGqz8bmxlaSUVB70m3o7TqvX6l4OyxEG4EwI8ixAKsxIicmxzzIqtGlWGqV/1ahnfIEktee0gF+cH/LcrBjUDblnOGUeKNCZlz7MBVIoRlWx5al5xcX1Vb63SekRlAuBqHqdEioYvMwm52QXzeXfNMDf+ixMhtpQ2fpAjG7PM+D0y+m2V9+3iUI/63e44RjWW2kZhgLrCQ9KSWrRoSsohGVS9zvGQBaVc6vb1QyX56A80eLKbqcGhWSHc+gp1L2lCM1MwBsvl9wM+Szzs8O61UBQJD4C/Pvyed1hxwt0Ebku70VyfTPJGLufWDisMOrruBmQ8MU+OyFk4N1Qm2ZkhOw2tE3yi9lf2ih0mfuj2a/nj81GIbvbSA+r+Oh3lhgOX7IlzidD6BJgSgWY4bG83a1twEVJyvBKZBIIDYKumDSz7tX9gWeKa5b90+RKGohyeM4Kp3yxuPGDPW6jxSHa6hch837OagyVjO/EVkB1LruWIAVAvFG9oAPYNesB2xFeeseXCnAQaNYbw6KMkH85kNBREvFZU1QpFHFWQ9gAxWfSTxCjnkqGao1In/b7wQVopgMVthrCr5VScf8HArkDdD8R9pnN9iD6he0mjmsFnw3mA80KxcWugmKiYkWrIfpEdbNB5FsyCffW+Wt9WmmFnH168qkhbkp5uXk3s8/bJFKmeijyNVWIG9LlLnZ6tpDkq8UqZSriapLq2byI1fk+Mu3UszAeen4qSDXQ32xGqKSr+SG/qpqVlFrvYr2PePjaZUljSqrH0CQSdqMvmlnM9E3XO4rQNtKcmg14BZ4FpVh7m/kCM0zfCfjSdX/BwWibEV4loM+eqLYyX7aul+chBDiQeO3iGkGWkfpycax2NDTYEMfimjuAJlWXsAuhJcdzLdeRucaZwJ0Fwpo0P153I8GIo7AQckiVaeBZRBY9fTEoz8khALMGRGSMrFDqqJ+1dDVdNxAh2txUeOneNi/D0rfM1W4yLJaB6Djl48VHoM1DguwSNDj4yE32Ku4XpXasoSd5/jRmmT8FKvk+FF4jxbJZcYHyTLcnyFCvDfrSBqdJAbdaLgkvRz9Gl1JAs3WPKR+CvIjbuf1cWxj8V8fyngf239aHVfePTXFXPi+M7uRo+923rjW7XxJortd68jHa7GpaLGUKVXgtKBXkAKCrRNRRwIJjODieyGYkj+GdUE0ikYCwbUPx8ZamGY3hjP8GjaVHK+9pUP7CddlUn3H3lIFbcXxQUtCOEFmwg9s9JcJSWLaoX0Vg0KIC1hYtamEkdiJSDbQWURPgzTD/llV86iDx/yaDgXWxaT4DkyNRfVcK2AFiDHQjHhRGd8KDizdpyCfAJZwTlF/nAPz4/FA1/bQ/cT5MHuebzbWbzkYDkDBzTXp2ND6VF50a3XUoKGV3LC6AEsUrUIDksKatHh0hLe+j/YMwOluyvI1WqfAHjCnAqjl/l29S/gFpAKMMjSS60OQzgp/hhORd+gDb8vj/9AS19B8uowIz8KnJkMHv57W3CKPROz2gBMl1VQYm5rRIayBn1fEtpwldDScyhFNtdx7G/ypjQLpgVqzdZTy1dZqHJwA1lOtoMfG1kz/SIDxF56kzUpUcc9Smz/hF3hhGn7TYsQZtcMRB1/W8JCq4qvUle0MIAOXn4XukmQXae6M25aHfHUad7H4BGrFP+P0398drrRQGaqSzos4Hpl1Ev6+3t4mlC81aeRi12asZoIuYTs+RxH4wSe/tbfX+579MuSm07iQGSSmbo8r9xcEREPjEJZ+hNl1YlZB3x/fpzMfVW9L0omiF+rPcTiiILmQh32LRkJ8ZDiAw4HXTZHYKR1sGe+NBqCjT5Y1ZGzQE+4RntZQ7L39qDN8N6VK1gSnL1pEsHui+kLBh8OF+VZWWw2625evbyZ4TPTOccgGf6oOnqNKNn1R6yu7ixK/eOVl1hWUbFZBaWcBvIw1IT4kjA63Q9C9cTqs2abxMzVdP4vdN1M5PC7vNoD9CYBS6GXqw+8B5ODgs6o7ktSNkeUas8XWFxb8xq+m/X112ujTuIcv7loDijzdmKwZq1YLhVEfPTtgGDxZLlGAwIbCvxK2WPjT7h9o+aDZpX3mf7Qwm6+mBMboz4i8b0QnSCIiSBsVx/tHbwA/bH8VwaIqqhZssQMhFCu3RoNlEQ5O9oV4qONmPDhuRNpt/ArBiCyCQ+N4LFvwVQr7t5y7ioW7JVCCoBSjBlQ3b2EEbH/4tbgt9VkCIXN5qSjcu/rmgMQvpYa4Mud6Hq4Z3GNe7jTgc084nD4E2pR5yqxkjgI7axtkHbDLcNKa2yNW7OwIrg0j+iK6AW6TMd/M3oXOH26FAUfdLi72tl6jcS3osbQZ+/8jed+frQdx2GGxT/NLvfLq002T+rgq4InFPcrzXHItfk6pNfAOnYPyB8zw8INSSpa5TOk11CKQ0eOv4SLDbuf8ffRNfOA4cl/dm4M6bANzx9R1RWdRt1bf05NPtzPN7lH32CtCzajFA1wmI6JpcXerG0QosKWjfltSdYTOE2+8SNW2xP3pTuQUpi7X5ewXeJZ06rtXcS09URjZCRd1Vob6zDavL1chuJOmK0eoZpCS0IlaLzIZsiaQXpQeAVVMA8+oUX/JHovkYD+iWy+Q12TtuIR6sOFR6upajX677lFRY+P8NjoharzWKBhZwkxII31I9aU/T59G5/qmWvi+HUiz7Ke/cbIceQeHejjbcyh4coq9K15SvthpAIkO9WohOXsJBIEGBUNA6bzree4nHOkPD9A34FXjjs6dFnbc9S9Edu8PlfKJ5E6I1z680J3UkoA749fHfK8wV5lIVqd81LLYQweT7U5ue2AQmAECxcXW0cBDsMv3mQL9wBcdKdxFOb9ZEKkako1REjm5RuIlJ9tcc9X5ZSE1vCh/enj3Lj1UrY2qNeTYCsSoSEoEmIyarDzG9q4Nd5K5fTW8IHKgDbwA3Al4Nu8fQLlc9OnHp8zUXXr6tfVrn/5LfqdjZANCJuIamnlg/kVBpp6364KbdapnI1B1FoEhKUZbqSx9IxgDpj1bY+0R9Apms4vV7bLFQQks8HsBFxZ80dlVxOt//YqOXy6x4oCmg8x5Gxb6z6oHybcO34LUIUHOHuC+Dcc0B+lAQjINHEn395KNcgZiO57Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-interactions",
|
||||
"provider:google",
|
||||
"protocol:google-interactions"
|
||||
],
|
||||
"name": "google-interactions/streams-text-and-reports-usage",
|
||||
"recordedAt": "2026-10-03T18:08:32.334Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly one word: hello\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TKZDuA7i8rhztLxjVI9+u+bpJI/x6/j60nie/dFgj5TyzAJCDDZQYfmeXEBrujJcdKT5FiKPuuMYWq1shTTILJ1JQpG2WkGAk6rAxxgxp7Ac0+rporJ7knHFF/jVWQD+AYGY=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"hello\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":9,\"total_input_tokens\":8,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":8}],\"total_cached_tokens\":0,\"total_output_tokens\":1,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":39,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}]},\"created\":\"2026-10-03T18:08:32Z\",\"updated\":\"2026-10-03T18:08:32Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,9 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": [
|
||||
"prefix:mistral-chat",
|
||||
"provider:mistral",
|
||||
"protocol:mistral-chat",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"usage"
|
||||
],
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
|
||||
"name": "mistral-chat/drives-a-tool-loop",
|
||||
"recordedAt": "2026-10-03T04:09:49.878Z"
|
||||
"recordedAt": "2026-08-30T17:18:49.552Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +15,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":120,\"total_tokens\":132,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghij\"}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -40,14 +33,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"EwgHkPRLW\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature of 1\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"8°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"total_tokens\":84,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -430,7 +430,6 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode/ai/providers/google")
|
||||
const GoogleInteractions = await import("@opencode/ai/providers/google/interactions")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
@@ -444,16 +443,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
|
||||
const interactions = GoogleInteractions.model("gemini-3.8-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
thinkingLevel: "low",
|
||||
store: true,
|
||||
})
|
||||
expect(interactions.route.id).toBe("google-interactions")
|
||||
expect(interactions.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
expect(interactions.route.defaults.providerOptions).toEqual({ thinkingLevel: "low", store: true })
|
||||
expect(Google.configure().interactions("gemini-3.8-flash").route.protocol).toBe("google-interactions")
|
||||
})
|
||||
|
||||
test("selects Vertex entrypoints with the same model contract", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/index.js"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { Cohere } from "../../src/providers/cohere.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
|
||||
@@ -55,20 +55,12 @@ recorded.effect(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* runWeatherToolLoop(
|
||||
LLMRequest.update(
|
||||
goldenWeatherToolLoopRequest({
|
||||
id: "cohere-tool-loop",
|
||||
model: cohere.model("command-a-plus-05-2026"),
|
||||
maxTokens: 2048,
|
||||
temperature: false,
|
||||
}),
|
||||
{
|
||||
system: [
|
||||
SystemPart.make("Use the get_weather tool exactly once."),
|
||||
SystemPart.make("After the tool result, reply exactly: Paris is sunny."),
|
||||
],
|
||||
},
|
||||
),
|
||||
goldenWeatherToolLoopRequest({
|
||||
id: "cohere-tool-loop",
|
||||
model: cohere.model("command-a-plus-05-2026"),
|
||||
maxTokens: 2048,
|
||||
temperature: false,
|
||||
}),
|
||||
)
|
||||
expectWeatherToolLoop(events)
|
||||
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/index.js"
|
||||
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
|
||||
import { Cohere } from "../../src/providers/cohere.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -23,7 +23,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-reasoning-08-2025"),
|
||||
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
|
||||
system: "Be concise.",
|
||||
messages: [
|
||||
Message.user("Lookup Paris"),
|
||||
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
|
||||
@@ -44,13 +44,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
|
||||
thinking: { type: "enabled", token_budget: 128 },
|
||||
tool_choice: "REQUIRED",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Be concise.\nKeep this newline." },
|
||||
{ type: "text", text: "Second instructions." },
|
||||
],
|
||||
},
|
||||
{ role: "system", content: "Be concise." },
|
||||
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { Google } from "../../src/providers.js"
|
||||
import { LLMClient, RequestExecutor } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { weatherTool } from "../recorded-scenarios.js"
|
||||
|
||||
const model = Google.configure({ apiKey: process.env.GEMINI_API_KEY ?? "fixture" }).interactions("gemini-3.8-flash")
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-interactions",
|
||||
provider: "google",
|
||||
protocol: "google-interactions",
|
||||
requires: ["GEMINI_API_KEY"],
|
||||
})
|
||||
const InteractionMetadata = Schema.Struct({ interactionId: Schema.String })
|
||||
|
||||
describe("Google Interactions recorded", () => {
|
||||
recorded.effect("streams text and reports usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Reply with exactly one word: hello",
|
||||
generation: { maxTokens: 2048 },
|
||||
providerOptions: { thinkingLevel: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().toLowerCase()).toBe("hello")
|
||||
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.contextTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.providerMetadata?.google).toMatchObject({ total_input_tokens: expect.any(Number) })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"streams reasoning and retains thought signatures",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt:
|
||||
"Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.",
|
||||
generation: { maxTokens: 4096 },
|
||||
providerOptions: { thinkingLevel: "high", thinkingSummaries: "auto" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
providerMetadata: { google: { interactionSignature: expect.any(String) } },
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"replays native tool results and signatures statelessly",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Use get_weather for weather questions. Answer concisely after receiving the result.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
tools: [weatherTool],
|
||||
toolChoice: { type: "tool", name: weatherTool.name },
|
||||
generation: { maxTokens: 2048 },
|
||||
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
const call = first.toolCalls[0]
|
||||
if (!call) throw new Error("Missing recorded weather tool call")
|
||||
expect(call.name).toBe(weatherTool.name)
|
||||
expect(call.input).toEqual({ city: "Paris" })
|
||||
expect(call.providerMetadata?.google).toHaveProperty("interactionSignature")
|
||||
const second = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system: request.system,
|
||||
tools: [weatherTool],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 2048 },
|
||||
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" },
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
expect(second.text).toContain("22")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"continues tool results with previous interaction id",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Use get_weather for weather questions. Answer concisely after receiving the result.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
tools: [weatherTool],
|
||||
toolChoice: "required",
|
||||
generation: { maxTokens: 2048 },
|
||||
providerOptions: { thinkingLevel: "low", store: true },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
const metadata = yield* Schema.decodeUnknownEffect(InteractionMetadata)(
|
||||
first.events.find(LLMEvent.is.finish)?.providerMetadata?.google,
|
||||
)
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const cleanup = executor
|
||||
.execute(
|
||||
HttpClientRequest.delete(
|
||||
`https://generativelanguage.googleapis.com/v1beta/interactions/${metadata.interactionId}`,
|
||||
).pipe(HttpClientRequest.setHeader("x-goog-api-key", process.env.GEMINI_API_KEY ?? "fixture")),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
const call = first.toolCalls[0]
|
||||
if (!call) throw new Error("Missing recorded weather tool call")
|
||||
const second = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system: request.system,
|
||||
tools: [weatherTool],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 2048 },
|
||||
providerOptions: { thinkingLevel: "low", previousInteractionId: metadata.interactionId, store: false },
|
||||
messages: [
|
||||
Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
expect(second.text).toContain("22")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
}).pipe(Effect.ensuring(cleanup))
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
@@ -41,7 +41,7 @@ describe("Mistral Chat", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
|
||||
system: "Initial",
|
||||
messages: [
|
||||
Message.system("Updated"),
|
||||
Message.user([
|
||||
@@ -105,13 +105,7 @@ describe("Mistral Chat", () => {
|
||||
reasoning_effort: "high",
|
||||
})
|
||||
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Initial\nKeep this newline." },
|
||||
{ type: "text", text: "Second instructions." },
|
||||
],
|
||||
},
|
||||
{ role: "system", content: "Initial" },
|
||||
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { configure } from "@opencode/ai/providers/mistral"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -97,10 +97,7 @@ describe("Mistral recorded", () => {
|
||||
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
|
||||
const firstRequest = LLM.request({
|
||||
model,
|
||||
system: [
|
||||
SystemPart.make("Call lookup_weather exactly once with Paris."),
|
||||
SystemPart.make("After the tool result, describe the weather briefly."),
|
||||
],
|
||||
system: "Call lookup_weather exactly once with Paris.",
|
||||
prompt: "What is the weather?",
|
||||
tools: [weather],
|
||||
toolChoice: weather,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
const modules = {
|
||||
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
|
||||
host: source("../src/runtime/extension/host.tsx"),
|
||||
@@ -14,74 +13,49 @@ const modules = {
|
||||
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
])
|
||||
|
||||
mountBrowserRegion({
|
||||
LanguageProvider: language.LanguageProvider,
|
||||
ExtensionHostProvider: host.ExtensionHostProvider,
|
||||
useExtensionHost: host.useExtensionHost,
|
||||
createRegion: panels.createRegion,
|
||||
definitions: [
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
})
|
||||
}, modules)
|
||||
})
|
||||
|
||||
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
const tree = root.getByTestId("tree")
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await expect(tabs).toHaveText(["alpha.ts"])
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
// Beta was left on its browser tab, which the desktop has not reported yet.
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
|
||||
await expect(tabs).toHaveText(["beta.ts"])
|
||||
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
await root.getByRole("button", { name: "First inventory", exact: true }).click()
|
||||
await expect(tabs).toHaveText(["beta.ts", "Preview"])
|
||||
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
})
|
||||
|
||||
story(
|
||||
"keeps the browser tabs while the pane's Ipc is away and registers them again when it returns",
|
||||
async ({ page }) => {
|
||||
"keeps a restored browser tab selected and undrawn until the desktop's first inventory",
|
||||
async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
])
|
||||
mountBrowserRegion({
|
||||
LanguageProvider: language.LanguageProvider,
|
||||
ExtensionHostProvider: host.ExtensionHostProvider,
|
||||
useExtensionHost: host.useExtensionHost,
|
||||
createRegion: panels.createRegion,
|
||||
definitions: [
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
})
|
||||
}, modules)
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
const tree = root.getByTestId("tree")
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await expect(tabs).toHaveText(["alpha.ts"])
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
// Beta was left on its browser tab, which the desktop has not reported yet.
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
|
||||
await expect(tabs).toHaveText(["beta.ts"])
|
||||
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
await root.getByRole("button", { name: "First inventory", exact: true }).click()
|
||||
await expect(tabs).toHaveText(["beta.ts", "Preview"])
|
||||
|
||||
// The pane's main extension reloads: every binding goes with it, and the strip keeps the tab it will restore.
|
||||
await root.getByRole("button", { name: "Pane away", exact: true }).click()
|
||||
await expect(tabs).toHaveText(["beta.ts", "Preview"])
|
||||
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Both attachments register again at once, without a retry timer; Beta hands main the tab to restore.
|
||||
await root.getByRole("button", { name: "Pane back", exact: true }).click()
|
||||
await expect(root.getByText("Registrations: 4", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Beta restores: 1", { exact: true })).toBeVisible()
|
||||
await expect(tabs).toHaveText(["beta.ts", "Preview"])
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
},
|
||||
)
|
||||
@@ -2,25 +2,23 @@ import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
const modules = {
|
||||
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
|
||||
embeds: source("../src/runtime/extension/embeds.tsx"),
|
||||
surface: source("../src/runtime/extension/surface.tsx"),
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the pane beside it on the real host embeds.
|
||||
// Any story loads the app styles; the fixture mounts the pane beside it on the real host surface.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserPane }, { createEmbeds }, language] = await Promise.all([
|
||||
const [{ mountBrowserPane }, { createSurfaces }, language] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.embeds),
|
||||
import(modules.surface),
|
||||
import(modules.language),
|
||||
])
|
||||
|
||||
mountBrowserPane({
|
||||
createEmbeds,
|
||||
createSurfaces,
|
||||
LanguageProvider: language.LanguageProvider,
|
||||
UiI18nBridge: language.UiI18nBridge,
|
||||
useLanguage: language.useLanguage,
|
||||
@@ -28,7 +26,6 @@ story.beforeEach(async ({ mount, page }) => {
|
||||
}, modules)
|
||||
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides a native page when another takes the pane, the pane hides, or it unmounts", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const alpha = root.getByTestId("native-Alpha")
|
||||
@@ -53,14 +50,11 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const toggle = root.getByRole("button", { name: "Toggle Review tab", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
// Read in the same task as the click so a deferred animation-frame hide cannot pass.
|
||||
const visible = await toggle.evaluate((element) => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }))
|
||||
|
||||
return document.querySelector('[data-testid="native-Alpha"]')?.getAttribute("data-visible")
|
||||
})
|
||||
|
||||
expect(visible).toBe("false")
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(1)
|
||||
await toggle.click()
|
||||
@@ -114,19 +108,6 @@ story("comments on a picked element over a still of the page", async ({ page })
|
||||
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
|
||||
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
|
||||
// The pane stays mounted when Beta is routed, and its picker then listens to Beta's page.
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await picker.click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.fill("Beta's button too")
|
||||
await editor.press("Enter")
|
||||
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([
|
||||
"button.primary @e7: Make this the primary colour",
|
||||
"button.primary @e7: Beta's button too",
|
||||
])
|
||||
})
|
||||
|
||||
story("cancels the picker and a comment with Escape", async ({ page }) => {
|
||||
@@ -176,7 +157,6 @@ story("keeps the comment editor and its actions inside the page", async ({ page
|
||||
.poll(async () => {
|
||||
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
|
||||
const box = await editor.boundingBox()
|
||||
|
||||
return !!surface && !!box && box.y + box.height <= surface.y + surface.height
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Definition } from "@opencode/gui-extensions/sdk"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
const modules = {
|
||||
fixture: source("./extension-host.fixture.tsx"),
|
||||
builtins: source("../../gui-extensions/src/renderer.ts"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
// Any story loads the app; the fixture mounts the real host beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
})
|
||||
|
||||
// The built-in renderer composition as a graph: `requires` edges must not form a cycle, and every optional (`uses`)
|
||||
// edge gets one row that boots the real host with that provider disabled. The first row disables nothing.
|
||||
story("built-ins: no requires cycle, and each consumer activates without each optional provider", async ({ page }) => {
|
||||
const result = await page.evaluate(async (modules) => {
|
||||
const [{ mountExtensions, until }, { builtins }] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.builtins),
|
||||
])
|
||||
|
||||
// The window this host serves has no OS, so OS-specific built-ins are left out as the app leaves them out.
|
||||
const composition: readonly Definition[] = builtins
|
||||
const definitions = composition.filter((definition) => !definition.os)
|
||||
const ids = (tokens: Definition["provides"]) => Object.values(tokens ?? {}).map((token) => token.id)
|
||||
const providerOf = (token: string) => definitions.find((definition) => ids(definition.provides).includes(token))?.id
|
||||
|
||||
const requires = new Map(
|
||||
definitions.map((definition) => [
|
||||
definition.id,
|
||||
ids(definition.requires).flatMap((token) => providerOf(token) ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
const cycles = definitions.flatMap((definition) => {
|
||||
const walk = (id: string, path: readonly string[]): string[][] =>
|
||||
path.includes(id)
|
||||
? id === definition.id
|
||||
? [[...path, id]]
|
||||
: []
|
||||
: (requires.get(id) ?? []).flatMap((next) => walk(next, [...path, id]))
|
||||
|
||||
return walk(definition.id, [])
|
||||
})
|
||||
|
||||
// A renderer that uses the Ipc its own main entry provides depends on no other extension.
|
||||
const edges = definitions.flatMap((consumer) =>
|
||||
ids(consumer.uses).flatMap((token) => {
|
||||
const provider = providerOf(token)
|
||||
|
||||
return provider && provider !== consumer.id ? [{ consumer: consumer.id, provider, token }] : []
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = [{ consumer: "*", provider: "", token: "" }, ...edges]
|
||||
const outcomes = []
|
||||
|
||||
for (const row of rows) {
|
||||
const host = mountExtensions({ definitions, disabled: row.provider ? [row.provider] : [] })
|
||||
host.release()
|
||||
await until(() => host.ready())
|
||||
const consumers = row.consumer === "*" ? definitions.map((definition) => definition.id) : [row.consumer]
|
||||
outcomes.push({
|
||||
...row,
|
||||
results: consumers.map((id) => ({ id, status: host.status(id), failure: host.failure(id)?.error ?? null })),
|
||||
})
|
||||
host.unmount()
|
||||
}
|
||||
|
||||
return { cycles, outcomes }
|
||||
}, modules)
|
||||
|
||||
expect(result.cycles).toEqual([])
|
||||
expect(result.outcomes).toEqual(
|
||||
result.outcomes.map((outcome) => ({
|
||||
...outcome,
|
||||
results: outcome.results.map((item) => ({ id: item.id, status: "active", failure: null })),
|
||||
})),
|
||||
)
|
||||
})
|
||||
@@ -1,138 +1,21 @@
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import {
|
||||
Panel,
|
||||
type Appearance,
|
||||
type Build,
|
||||
type Definition,
|
||||
type Keybinds,
|
||||
type Layout,
|
||||
type Locale,
|
||||
type MountedSession,
|
||||
type Router,
|
||||
type Servers,
|
||||
type SessionScreen,
|
||||
type Setup,
|
||||
type Storage,
|
||||
type StoreOptions,
|
||||
type Workspaces,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { createMemo, createSignal, getOwner, onCleanup, runWithOwner, Show, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import type { Setup } from "@opencode/gui-extensions/sdk"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { ExtensionHostProvider, useExtensionHost, type HostApiFactories } from "../src/runtime/extension/host"
|
||||
import { createHostApis } from "../src/runtime/extension/host-apis"
|
||||
import { createRegion, RegionContent } from "../src/runtime/extension/panels"
|
||||
import { ExtensionSlot } from "../src/runtime/extension/render"
|
||||
import { persistedHandle } from "../src/runtime/extension/stores"
|
||||
import { ExtensionHostProvider, useExtensionHost } from "../src/runtime/extension/host"
|
||||
import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
|
||||
export { Contract, createKeyed, Panel, Slot, Store } from "@opencode/gui-extensions/sdk"
|
||||
|
||||
export { Schema }
|
||||
|
||||
type ExtensionHost = ReturnType<typeof useExtensionHost>
|
||||
|
||||
/** What the app interface hands the real host APIs when it mounts. */
|
||||
type Interface = Parameters<ReturnType<typeof createHostApis>["attach"]>[0]
|
||||
|
||||
/** The host APIs a mounted fixture created, once it rendered. */
|
||||
type Captured = { apis?: ReturnType<typeof createHostApis> }
|
||||
|
||||
/** A value the fixture's storage holds as JSON. */
|
||||
type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json }
|
||||
|
||||
export { createMemo, createSignal, getOwner, onCleanup, runWithOwner }
|
||||
|
||||
const layout: Layout = {
|
||||
narrow: () => false,
|
||||
ready: () => true,
|
||||
open() {},
|
||||
close() {},
|
||||
toggle() {},
|
||||
state: () => "closed",
|
||||
stored: () => [],
|
||||
side: { opened: () => false, toggle() {} },
|
||||
sidebar: { opened: () => true },
|
||||
dock: { opened: () => false, placement: () => "bottom" },
|
||||
scroll: { get: () => undefined, set() {} },
|
||||
settings() {},
|
||||
project() {},
|
||||
}
|
||||
|
||||
const build: Build = { version: "", channel: "dev", platform: "desktop", packaged: false }
|
||||
|
||||
const locale: Locale = { locale: () => "en", direction: () => "ltr", setDirection() {} }
|
||||
|
||||
const appearance: Appearance = { font: () => "monospace" }
|
||||
|
||||
const router: Router = { routing: () => false, path: () => "/" }
|
||||
|
||||
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
|
||||
|
||||
const servers: Servers = { list: () => [] }
|
||||
|
||||
const workspaces: Workspaces = { on: () => () => undefined }
|
||||
|
||||
/** Every HostApi faked at its boundary, with the given storage. */
|
||||
function fakeApis(storage: (extension: string) => Storage): HostApiFactories {
|
||||
return {
|
||||
build: () => build,
|
||||
locale: () => locale,
|
||||
appearance: () => appearance,
|
||||
router: () => router,
|
||||
keybinds: () => keybinds,
|
||||
servers: () => servers,
|
||||
workspaces: () => workspaces,
|
||||
desktop: () => undefined,
|
||||
sessions: () => ({ list: () => [], current: () => undefined }),
|
||||
screen: () => ({ current: () => undefined }),
|
||||
layout: () => layout,
|
||||
storage,
|
||||
system: () => ({ copy: async () => {}, save: async () => false, openExternal() {} }),
|
||||
embeds: () => ({ View: () => null, capture: async () => undefined }),
|
||||
}
|
||||
}
|
||||
|
||||
/** The fake HostApis' app interface, which is always mounted: a dialog shows at once. */
|
||||
function mounted(run: () => void) {
|
||||
run()
|
||||
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
/** Storage that no test of `mountExtensionHost` reads. */
|
||||
const unused = (): Storage => {
|
||||
throw new Error("The fixture extension reads no storage")
|
||||
}
|
||||
|
||||
/** Resolves once `check` holds, checking every frame; rejects after five seconds. */
|
||||
export async function until(check: () => boolean) {
|
||||
const deadline = performance.now() + 5000
|
||||
|
||||
while (!check()) {
|
||||
if (performance.now() > deadline) throw new Error("The fixture never reached the expected state")
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
/** Mounts the real extension host with one extension whose every renderer load settles when the test says so. */
|
||||
export function mountExtensionHost() {
|
||||
const loads: PromiseWithResolvers<{ default: Setup<Definition> }>[] = []
|
||||
const loads: PromiseWithResolvers<{ default: Setup }>[] = []
|
||||
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const hosts: ExtensionHost[] = []
|
||||
const state = { host: undefined as ReturnType<typeof useExtensionHost> | undefined }
|
||||
const container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
|
||||
function Capture() {
|
||||
hosts.push(useExtensionHost())
|
||||
|
||||
state.host = useExtensionHost()
|
||||
return null
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
@@ -142,16 +25,14 @@ export function mountExtensionHost() {
|
||||
{
|
||||
id: "fixture",
|
||||
renderer: () => {
|
||||
const load = Promise.withResolvers<{ default: Setup<Definition> }>()
|
||||
const load = Promise.withResolvers<{ default: Setup }>()
|
||||
loads.push(load)
|
||||
|
||||
return load.promise
|
||||
},
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
apis={fakeApis(unused)}
|
||||
whenMounted={mounted}
|
||||
services={[]}
|
||||
>
|
||||
<Capture />
|
||||
</ExtensionHostProvider>
|
||||
@@ -160,359 +41,22 @@ export function mountExtensionHost() {
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
/** Resolves the nth renderer load (the first by default) with this setup. */
|
||||
load: (setup: Setup<Definition>, index = 0) => loads[index].resolve({ default: setup }),
|
||||
load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }),
|
||||
/** Rejects the nth renderer load. */
|
||||
fail: (index: number, cause: unknown) => loads[index].reject(cause),
|
||||
fail: (index: number, error: unknown) => loads[index].reject(error),
|
||||
/** Renderer loads requested so far. */
|
||||
count: () => loads.length,
|
||||
reload: () => hosts[0]?.reload("fixture"),
|
||||
reload: () => state.host?.reload("fixture"),
|
||||
disable: () => setDisabled(new Set(["fixture"])),
|
||||
enable: () => setDisabled(new Set<string>()),
|
||||
status: () => hosts[0]?.state.status.fixture,
|
||||
status: () => state.host?.state.status.fixture,
|
||||
/** Contributions the host holds for a point; readable after the host unmounts. */
|
||||
entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host over these definitions, before any session mounts, with the HostApis faked at their
|
||||
* boundary. Storage is the real persisted store of a desktop window whose reads wait until `release()`; `stored` seeds
|
||||
* it. Renders the `window.bottom` slot once the startup gate opens.
|
||||
*/
|
||||
export function mountExtensions(input: {
|
||||
definitions: readonly Definition[]
|
||||
disabled?: readonly string[]
|
||||
stored?: Readonly<Record<string, Json>>
|
||||
}) {
|
||||
const held = Promise.withResolvers<void>()
|
||||
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set(input.disabled ?? []))
|
||||
const hosts: ExtensionHost[] = []
|
||||
const container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "desktop",
|
||||
windowID: "extension-host-fixture",
|
||||
openExternal: () => undefined,
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
restart: async () => undefined,
|
||||
notify: async () => undefined,
|
||||
storage: () => ({
|
||||
getItem: async (key: string) => {
|
||||
await held.promise
|
||||
|
||||
return key in (input.stored ?? {}) ? JSON.stringify(input.stored?.[key]) : null
|
||||
},
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
const storage = (extension: string): Storage => ({
|
||||
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>) {
|
||||
const pair = persisted(Persist.global(`extension.${extension}.${key}`), options.schema, options.initial, platform)
|
||||
|
||||
return persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
|
||||
},
|
||||
memory: (_key, options) => {
|
||||
const [value, set] = createStore(options.initial)
|
||||
|
||||
return [value, (mutation) => set(produce(mutation))] as const
|
||||
},
|
||||
remove() {},
|
||||
})
|
||||
|
||||
function MountedHost() {
|
||||
return (
|
||||
<ExtensionHostProvider
|
||||
definitions={input.definitions}
|
||||
disabled={disabled}
|
||||
apis={fakeApis(storage)}
|
||||
whenMounted={mounted}
|
||||
>
|
||||
<Capture />
|
||||
</ExtensionHostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Capture() {
|
||||
const host = useExtensionHost()
|
||||
hosts.push(host)
|
||||
|
||||
return (
|
||||
<Show when={host.ready()}>
|
||||
<ExtensionSlot at="window.bottom" input={{}} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<DialogProvider>
|
||||
<MountedHost />
|
||||
</DialogProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
/** Lets the held storage reads answer. */
|
||||
release: () => held.resolve(),
|
||||
disable: (ids: readonly string[]) => setDisabled(new Set(ids)),
|
||||
reload: (id: string) => hosts[0]?.reload(id),
|
||||
ready: () => hosts[0]?.ready() ?? false,
|
||||
status: (id: string) => hosts[0]?.state.status[id],
|
||||
failure: (id: string) => hosts[0]?.state.failures[id],
|
||||
entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const web: Platform = {
|
||||
platform: "web",
|
||||
openExternal: () => undefined,
|
||||
restart: async () => undefined,
|
||||
notify: async () => undefined,
|
||||
}
|
||||
|
||||
/** A stand-in for the app interface the real host APIs attach to; each test overrides what it observes. */
|
||||
function standIn(overrides: Partial<Interface>): Interface {
|
||||
return {
|
||||
sessions: () => [],
|
||||
current: () => undefined,
|
||||
screen: () => undefined,
|
||||
scope: () => ServerScope.local,
|
||||
scoped: () => undefined,
|
||||
layout: {
|
||||
ready: () => true,
|
||||
open() {},
|
||||
close() {},
|
||||
toggle() {},
|
||||
state: () => "closed",
|
||||
stored: () => [],
|
||||
side: { opened: () => true, toggle() {} },
|
||||
sidebar: { opened: () => false },
|
||||
dock: { opened: () => false, placement: () => "bottom" },
|
||||
scroll: { get: () => undefined, set() {} },
|
||||
},
|
||||
settings() {},
|
||||
project() {},
|
||||
font: () => "fixture mono",
|
||||
routing: () => false,
|
||||
path: () => "/",
|
||||
keybind: () => [],
|
||||
matches: () => false,
|
||||
servers: () => ["local"],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** The real host over the real HostApis of a web window; `mounted`, when given, attaches as the app interface. */
|
||||
function RealHost(props: ParentProps<{ definitions: readonly Definition[]; captured: Captured; mounted?: Interface }>) {
|
||||
const apis = createHostApis()
|
||||
props.captured.apis = apis
|
||||
|
||||
if (props.mounted) onCleanup(apis.attach(props.mounted))
|
||||
|
||||
return (
|
||||
<ExtensionHostProvider
|
||||
definitions={props.definitions}
|
||||
disabled={() => new Set<string>()}
|
||||
apis={apis.apis}
|
||||
whenMounted={apis.whenMounted}
|
||||
>
|
||||
{props.children}
|
||||
</ExtensionHostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host and HostApis, and the parts of the session screen that render extension content for the routed
|
||||
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, and the
|
||||
* `session.panel.end` slot, whose input the screen builds this way. `route(id)` routes a session the way the screen
|
||||
* does: a new session object each time, delivered through the same reactive inputs, while the one screen object
|
||||
* `ctx.screen` returns follows the route. Its composer records which session each attachment reached.
|
||||
*/
|
||||
export function mountSessionRegion(input: { definitions: readonly Definition[]; active: string }) {
|
||||
const container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
const hosts: ExtensionHost[] = []
|
||||
const captured: Captured = {}
|
||||
const attached: string[] = []
|
||||
|
||||
const object = (id: string) => {
|
||||
const value = { key: `fixture\n${id}`, id, tab: id, pending: false, location: { directory: "/repo" }, visit: {} }
|
||||
|
||||
// SAFETY: the region and slot pass the object through; the test's extension reads only `key` and `id`.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
return value as unknown as MountedSession
|
||||
}
|
||||
|
||||
const [view, setView] = createSignal(object("a"))
|
||||
|
||||
const route = {
|
||||
get session() {
|
||||
return view()
|
||||
},
|
||||
composer: { attach: () => void attached.push(view().id), update() {}, detach() {} },
|
||||
}
|
||||
|
||||
// SAFETY: the test's extension reads only `session` and `composer.attach` of the screen.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const screen = route as unknown as SessionScreen
|
||||
|
||||
const sidebar = { opened: () => false, width: () => 0, transition: () => false, resize() {}, toggle() {} }
|
||||
|
||||
function Region() {
|
||||
const host = useExtensionHost()
|
||||
hosts.push(host)
|
||||
// Every panel's `main` tab is stored, and `active` selected, as a strip restored after a reload.
|
||||
const all = () => host.items(Panel).map((item) => `${item.extension}:main`)
|
||||
|
||||
const region = createRegion({
|
||||
region: "side",
|
||||
view,
|
||||
tabs: () => ({
|
||||
all,
|
||||
active: () => input.active,
|
||||
setAll() {},
|
||||
setActive() {},
|
||||
close() {},
|
||||
remap() {},
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={host.ready()}>
|
||||
<ExtensionSlot
|
||||
at="session.panel.end"
|
||||
input={{
|
||||
get session() {
|
||||
return view()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<RegionContent
|
||||
region={region}
|
||||
view={view()}
|
||||
frame={{
|
||||
shown: () => true,
|
||||
present: () => true,
|
||||
placement: () => "side",
|
||||
reserve: () => false,
|
||||
animate: () => false,
|
||||
sidebar,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<PlatformProvider value={web}>
|
||||
<DialogProvider>
|
||||
<RealHost
|
||||
definitions={input.definitions}
|
||||
captured={captured}
|
||||
mounted={standIn({ current: view, screen: () => screen })}
|
||||
>
|
||||
<Region />
|
||||
</RealHost>
|
||||
</DialogProvider>
|
||||
</PlatformProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
container,
|
||||
/** Routes a session: the screen creates a new session object for it. */
|
||||
route: (id: string) => setView(object(id)),
|
||||
/** The session each composer attachment reached, in order. */
|
||||
attached,
|
||||
status: (id: string) => hosts[0]?.state.status[id],
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host over the real HostApis of a web window, before the app interface mounts. `attach()` mounts a
|
||||
* stand-in for the interface: its layout records each write and reports a tab it opened as visible, its server is
|
||||
* the local one, and its mono font is "fixture mono".
|
||||
*/
|
||||
export function mountHostApis(input: { definitions: readonly Definition[] }) {
|
||||
const container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
const writes: string[] = []
|
||||
const [opened, setOpened] = createSignal<readonly string[]>([])
|
||||
const hosts: ExtensionHost[] = []
|
||||
const captured: Captured = {}
|
||||
const layout = standIn({}).layout
|
||||
|
||||
const mounted = standIn({
|
||||
layout: {
|
||||
...layout,
|
||||
open(key) {
|
||||
writes.push(`open ${key}`)
|
||||
setOpened((keys) => [...keys, key])
|
||||
},
|
||||
close: (key) => void writes.push(`close ${key}`),
|
||||
toggle: (key) => void writes.push(`toggle ${key}`),
|
||||
state: (key) => (opened().includes(key) ? "visible" : "closed"),
|
||||
scroll: { get: () => undefined, set: (_session, key) => void writes.push(`scroll ${key}`) },
|
||||
},
|
||||
settings: (page) => void writes.push(`settings ${page}`),
|
||||
})
|
||||
|
||||
function Capture() {
|
||||
hosts.push(useExtensionHost())
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<PlatformProvider value={web}>
|
||||
<DialogProvider>
|
||||
<RealHost definitions={input.definitions} captured={captured}>
|
||||
<Capture />
|
||||
</RealHost>
|
||||
</DialogProvider>
|
||||
</PlatformProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
/** Layout writes the interface received, in order. */
|
||||
writes,
|
||||
/** Mounts the interface. */
|
||||
attach: () => void captured.apis?.attach(mounted),
|
||||
status: (id: string) => hosts[0]?.state.status[id],
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
entries: (point: string) => state.host?.state.entries[point]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Owner } from "solid-js"
|
||||
import type { Context, Contract, DialogHandle, Dialogs, SessionRef } from "@opencode/gui-extensions/sdk"
|
||||
import type { Context, Dialogs } from "@opencode/gui-extensions/sdk"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./extension-host.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
/** What the scoped-registration case keeps from inside its extension. */
|
||||
type Scope = { owner?: Owner | null; end: () => void; ctx?: Context }
|
||||
|
||||
/** The context the session-routing case keeps from inside its extension. */
|
||||
type Kept = { ctx?: Context }
|
||||
|
||||
/** What the pre-mount case reads from inside its extension's setup. */
|
||||
type Reads = { state?: () => string; font?: () => string; prefs?: { value?: { count: number }; ready(): boolean } }
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
// Any story loads the app; the fixture mounts the real host beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
@@ -27,41 +17,35 @@ story("an extension that finishes loading after the host unmounts is never set u
|
||||
host.unmount()
|
||||
host.load(() => void state.setups++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
return state.setups
|
||||
}, fixture)
|
||||
|
||||
expect(setups).toBe(0)
|
||||
})
|
||||
|
||||
story("an async setup that resolves after the host unmounts releases everything it registers", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost, onCleanup } = await import(fixture)
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const resume = Promise.withResolvers<void>()
|
||||
const cleaned: string[] = []
|
||||
const point = { kind: "point" as const, id: "fixture-point" }
|
||||
const resumed = { aborted: false }
|
||||
host.load(async (ctx: Context) => {
|
||||
ctx.add(point, "before")
|
||||
onCleanup(() => void cleaned.push("owner"))
|
||||
started.resolve()
|
||||
await resume.promise
|
||||
// After an await there is no owner: the signal tells the setup it outlived its instance.
|
||||
resumed.aborted = ctx.signal.aborted
|
||||
ctx.add(point, "after")
|
||||
ctx.cleanup(() => void cleaned.push("registered"))
|
||||
return () => void cleaned.push("returned")
|
||||
})
|
||||
await started.promise
|
||||
const before = host.entries(point.id)
|
||||
host.unmount()
|
||||
resume.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
return { before, after: host.entries(point.id), cleaned, aborted: resumed.aborted }
|
||||
return { before, after: host.entries(point.id), cleaned }
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ before: 1, after: 0, cleaned: ["owner"], aborted: true })
|
||||
expect(result).toEqual({ before: 1, after: 0, cleaned: ["registered", "returned"] })
|
||||
})
|
||||
|
||||
story("older loads neither set up nor fail over the replacement after reloads", async ({ page }) => {
|
||||
@@ -78,10 +62,8 @@ story("older loads neither set up nor fail over the replacement after reloads",
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const outcome = { setups, status: host.status() }
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ setups: ["third"], status: "active" })
|
||||
})
|
||||
|
||||
@@ -89,8 +71,8 @@ story("a dialog service kept from before a reload opens and closes nothing under
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const dialogs: Dialogs[] = []
|
||||
const setup = (ctx: Context) => void dialogs.push(ctx.dialogs)
|
||||
const services: Dialogs[] = []
|
||||
const setup = (ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs)
|
||||
const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value })
|
||||
const shown = (value: string) => !!document.body.textContent?.includes(value)
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -99,248 +81,37 @@ story("a dialog service kept from before a reload opens and closes nothing under
|
||||
host.reload()
|
||||
host.load(setup, 1)
|
||||
await wait(20)
|
||||
const [stale, fresh] = dialogs
|
||||
const kept = stale.open(text("stale dialog"))
|
||||
fresh.open(text("fresh dialog"))
|
||||
const [stale, fresh] = services
|
||||
stale.push(text("stale dialog"))
|
||||
fresh.push(text("fresh dialog"))
|
||||
await wait(50)
|
||||
kept.close()
|
||||
stale.close()
|
||||
await wait(300)
|
||||
const outcome = { stale: shown("stale dialog"), fresh: shown("fresh dialog") }
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ stale: false, fresh: true })
|
||||
})
|
||||
|
||||
story("a dialog handle closes its own dialog, and a dialog closes with the scope that opened it", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost, createKeyed, createSignal } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value })
|
||||
|
||||
const shown = () =>
|
||||
["below", "middle", "scoped", "cancelled", "brief"].filter((value) => document.body.textContent?.includes(value))
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const scope = { end: () => {} }
|
||||
const handles: DialogHandle[] = []
|
||||
host.load((ctx: Context) => {
|
||||
const [on, set] = createSignal(true)
|
||||
scope.end = () => set(false)
|
||||
handles.push(ctx.dialogs.open(text("below")), ctx.dialogs.open(text("middle")))
|
||||
createKeyed(on, () => void ctx.dialogs.open(text("scoped")))
|
||||
// Ended in the tick they open, before the deferred opening: neither shows, and the replacing one replaces nothing.
|
||||
ctx.dialogs.open(text("cancelled"), { replace: true }).close()
|
||||
const [brief, end] = createSignal(true)
|
||||
createKeyed(brief, () => void ctx.dialogs.open(text("brief")))
|
||||
end(false)
|
||||
}, 0)
|
||||
await wait(100)
|
||||
const opened = shown()
|
||||
// The middle dialog is not on top: its handle closes it and nothing else.
|
||||
handles[1]?.close()
|
||||
await wait(300)
|
||||
const closed = shown()
|
||||
scope.end()
|
||||
await wait(300)
|
||||
const ended = shown()
|
||||
host.unmount()
|
||||
|
||||
return { opened, closed, ended }
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({
|
||||
opened: ["below", "middle", "scoped"],
|
||||
closed: ["below", "scoped"],
|
||||
ended: ["below"],
|
||||
})
|
||||
})
|
||||
|
||||
story("a dialog pushed in the same tick as a reload never mounts", async ({ page }) => {
|
||||
const shown = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const dialogs: Dialogs[] = []
|
||||
const services: Dialogs[] = []
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
host.load((ctx: Context) => void dialogs.push(ctx.dialogs), 0)
|
||||
host.load((ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs), 0)
|
||||
await wait(20)
|
||||
dialogs[0].open(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" }))
|
||||
services[0].push(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" }))
|
||||
host.reload()
|
||||
await wait(300)
|
||||
const outcome = !!document.body.textContent?.includes("same tick dialog")
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(shown).toBe(false)
|
||||
})
|
||||
|
||||
story(
|
||||
"routing A, B, then A again: a kept session stays A, the same screen follows the route, and no render remounts",
|
||||
async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountSessionRegion, until, Panel, Slot } = await import(fixture)
|
||||
const mounts = { single: 0, grouped: 0, slot: 0 }
|
||||
const inputs: Partial<Record<keyof typeof mounts, { readonly session: { readonly id: string } }>> = {}
|
||||
const kept: Kept = {}
|
||||
const node = () => document.createElement("p")
|
||||
|
||||
// One render of each kind the host has: a selected tab, a group that stays mounted, and a slot.
|
||||
const render = (kind: keyof typeof mounts) => (input: { readonly session: { readonly id: string } }) => {
|
||||
mounts[kind]++
|
||||
inputs[kind] = input
|
||||
|
||||
return node()
|
||||
}
|
||||
|
||||
const host = mountSessionRegion({
|
||||
active: "single:main",
|
||||
definitions: [
|
||||
{
|
||||
id: "single",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
const tab = { id: "main", title: "Single" }
|
||||
kept.ctx = ctx
|
||||
ctx.add(Panel, { id: "main", region: "side", list: () => [tab], render: render("single") })
|
||||
ctx.add(Slot, { at: "session.panel.end", render: render("slot") })
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "grouped",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
const tab = { id: "main", title: "Grouped", group: "group" }
|
||||
ctx.add(Panel, { id: "main", region: "side", list: () => [tab], render: render("grouped") })
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(() => mounts.single > 0 && mounts.grouped > 0 && mounts.slot > 0)
|
||||
|
||||
// Session A's object, as an extension keeps it, and the screen while it routes A.
|
||||
const session = kept.ctx?.sessions.current()
|
||||
const screen = kept.ctx?.screen.current()
|
||||
|
||||
const seen = () => ({
|
||||
mounts: { ...mounts },
|
||||
sessions: [inputs.single?.session.id, inputs.grouped?.session.id, inputs.slot?.session.id],
|
||||
kept: session?.id,
|
||||
fresh: kept.ctx?.sessions.current() !== session,
|
||||
screen: { same: kept.ctx?.screen.current() === screen, session: screen?.session?.id },
|
||||
})
|
||||
|
||||
const steps = [seen()]
|
||||
host.route("b")
|
||||
steps.push(seen())
|
||||
// An action through the screen targets the session routed now.
|
||||
screen?.composer.attach({ type: "file", path: "notes.md" })
|
||||
host.route("a")
|
||||
steps.push(seen())
|
||||
host.unmount()
|
||||
|
||||
return { steps, attached: host.attached }
|
||||
}, fixture)
|
||||
|
||||
const step = (id: string, fresh: boolean) => ({
|
||||
mounts: { single: 1, grouped: 1, slot: 1 },
|
||||
sessions: [id, id, id],
|
||||
kept: "a",
|
||||
fresh,
|
||||
screen: { same: true, session: id },
|
||||
})
|
||||
|
||||
// Routing A again makes a new object for the new visit; the kept one still names A.
|
||||
expect(result).toEqual({ steps: [step("a", false), step("b", true), step("a", true)], attached: ["b"] })
|
||||
},
|
||||
)
|
||||
|
||||
story(
|
||||
"before the app interface mounts, host APIs read their defaults and keep writes and dialogs until it mounts",
|
||||
async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountHostApis, until, createMemo, Schema } = await import(fixture)
|
||||
const Prefs = Schema.Struct({ count: Schema.Number })
|
||||
const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value })
|
||||
const shown = () => ["kept", "cancelled"].filter((value) => document.body.textContent?.includes(value))
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const fake = { key: "local\nses_fixture", id: "ses_fixture", tab: "tab", pending: false, location: undefined }
|
||||
// SAFETY: the stand-in interface reads only the key it is given; the host APIs pass the session through.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const session = fake as unknown as SessionRef
|
||||
|
||||
const reads: Reads = {}
|
||||
|
||||
const host = mountHostApis({
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
// Setup runs before the interface mounts, as every built-in's does.
|
||||
ctx.layout.open("fixture:main", session)
|
||||
ctx.layout.settings("fixture")
|
||||
// A dialog waits for the interface too; one its handle closes meanwhile never shows.
|
||||
ctx.dialogs.open(text("kept"))
|
||||
ctx.dialogs.open(text("cancelled")).close()
|
||||
|
||||
const prefs = ctx.storage.store("prefs", {
|
||||
schema: Prefs,
|
||||
initial: { count: 0 },
|
||||
scope: { server: "local" },
|
||||
})
|
||||
|
||||
prefs.update((draft) => ({ count: draft.count + 1 }))
|
||||
reads.state = createMemo(() => ctx.layout.state("fixture:main", session))
|
||||
reads.font = createMemo(() => ctx.appearance.font("mono"))
|
||||
reads.prefs = prefs
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const read = () => ({
|
||||
state: reads.state?.(),
|
||||
font: reads.font?.().startsWith('"JetBrainsMono Nerd Font Mono"') ? "default mono" : reads.font?.(),
|
||||
prefs: reads.prefs?.value?.count,
|
||||
ready: reads.prefs?.ready(),
|
||||
writes: [...host.writes],
|
||||
dialogs: shown(),
|
||||
})
|
||||
|
||||
await until(() => host.status("fixture") === "active")
|
||||
// Long enough for a dialog's deferred opening to have shown it.
|
||||
await wait(300)
|
||||
const before = read()
|
||||
host.attach()
|
||||
await until(() => host.writes.length === 2 && shown().length > 0)
|
||||
const after = read()
|
||||
host.unmount()
|
||||
|
||||
return { before, after }
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({
|
||||
before: { state: "closed", font: "default mono", prefs: undefined, ready: false, writes: [], dialogs: [] },
|
||||
after: {
|
||||
state: "visible",
|
||||
font: "fixture mono",
|
||||
prefs: 1,
|
||||
ready: true,
|
||||
writes: ["open fixture:main", "settings fixture"],
|
||||
dialogs: ["kept"],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
story("an extension reloaded while disabled starts when it is enabled again", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
@@ -353,7 +124,6 @@ story("an extension reloaded while disabled starts when it is enabled again", as
|
||||
await wait(20)
|
||||
host.reload()
|
||||
const afterReload = host.status()
|
||||
|
||||
// A load the reload started finishes while the extension is still disabled.
|
||||
if (host.count() > 1) host.load(() => void setups.push("while disabled"), 1)
|
||||
await wait(20)
|
||||
@@ -363,196 +133,7 @@ story("an extension reloaded while disabled starts when it is enabled again", as
|
||||
await wait(50)
|
||||
const outcome = { afterReload, setups, status: host.status() }
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ afterReload: "disabled", setups: ["first", "enabled"], status: "active" })
|
||||
})
|
||||
|
||||
story(
|
||||
"a registration is withdrawn with the scope that made it, and at once when that scope already ended",
|
||||
async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensions, until, createKeyed, createSignal, getOwner, runWithOwner } = await import(fixture)
|
||||
const point = { kind: "point" as const, id: "fixture-scoped" }
|
||||
const scope: Scope = { end: () => {} }
|
||||
|
||||
const host = mountExtensions({
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
const [on, set] = createSignal(true)
|
||||
scope.end = () => set(false)
|
||||
scope.ctx = ctx
|
||||
createKeyed(on, () => {
|
||||
scope.owner = getOwner()
|
||||
ctx.add(point, "during")
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(() => host.status("fixture") === "active")
|
||||
const during = host.entries(point.id)
|
||||
scope.end()
|
||||
const ended = host.entries(point.id)
|
||||
// A captured owner of a generation that ended, as async work resuming late would hold.
|
||||
runWithOwner(scope.owner, () => scope.ctx?.add(point, "late"))
|
||||
const late = host.entries(point.id)
|
||||
host.unmount()
|
||||
|
||||
return { during, ended, late }
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ during: 1, ended: 0, late: 0 })
|
||||
},
|
||||
)
|
||||
|
||||
story("a contribution that throws renders nothing and records the error; the others stay", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensions, until, Slot } = await import(fixture)
|
||||
const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value })
|
||||
|
||||
const host = mountExtensions({
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
ctx.add(Slot, {
|
||||
at: "window.bottom",
|
||||
render: () => {
|
||||
throw new Error("broken contribution")
|
||||
},
|
||||
})
|
||||
ctx.add(Slot, { at: "window.bottom", render: text("kept") })
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context) => void ctx.add(Slot, { at: "window.bottom", render: text("other") }),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(() => !!host.failure("fixture") && !!host.container.textContent?.includes("other"))
|
||||
const failure = host.failure("fixture")
|
||||
|
||||
const outcome = {
|
||||
text: host.container.textContent,
|
||||
status: [host.status("fixture"), host.status("other")],
|
||||
failure: { phase: failure?.phase, named: !!failure?.error.includes("broken contribution") },
|
||||
}
|
||||
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "keptother",
|
||||
status: ["active", "active"],
|
||||
failure: { phase: "render", named: true },
|
||||
})
|
||||
})
|
||||
|
||||
story("an extension that requires a contract starts once it is active and restarts with it", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensions, until, Contract, onCleanup } = await import(fixture)
|
||||
const Tree: Contract<{ version: number }, "provider.tree"> = Contract.define("provider.tree")
|
||||
const providerLoad = Promise.withResolvers<void>()
|
||||
const log: string[] = []
|
||||
const versions = { value: 0 }
|
||||
|
||||
const definitions = [
|
||||
{
|
||||
id: "provider",
|
||||
provides: { tree: Tree },
|
||||
renderer: async () => {
|
||||
await providerLoad.promise
|
||||
|
||||
return { default: (ctx: Context) => void ctx.provide(Tree, { version: ++versions.value }) }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "consumer",
|
||||
requires: { tree: Tree },
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context & { requires: { tree: { version: number } } }) => {
|
||||
const version = ctx.requires.tree.version
|
||||
log.push(`setup ${version}`)
|
||||
onCleanup(() => void log.push(`cleanup ${version}`))
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// Hard contracts gate startup: a consumer whose provider is disabled settles the gate without starting.
|
||||
const gated = mountExtensions({ definitions, disabled: ["provider"] })
|
||||
await until(() => gated.ready())
|
||||
const blocked = { status: gated.status("consumer"), log: [...log] }
|
||||
gated.unmount()
|
||||
|
||||
const host = mountExtensions({ definitions })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
const waiting = { status: host.status("consumer"), log: [...log] }
|
||||
providerLoad.resolve()
|
||||
await until(() => host.status("consumer") === "active")
|
||||
host.reload("provider")
|
||||
await until(() => log.length === 3)
|
||||
host.disable(["provider"])
|
||||
await until(() => log.length === 4)
|
||||
const outcome = { blocked, waiting, log, after: host.status("consumer") }
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({
|
||||
blocked: { status: "loading", log: [] },
|
||||
waiting: { status: "loading", log: [] },
|
||||
log: ["setup 1", "cleanup 1", "setup 2", "cleanup 2"],
|
||||
after: "loading",
|
||||
})
|
||||
})
|
||||
|
||||
story("declared global stores load before setup, so setup reads the stored value", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensions, until, Schema, Store } = await import(fixture)
|
||||
const Prefs = Schema.Struct({ open: Schema.Boolean })
|
||||
const seen: unknown[] = []
|
||||
|
||||
const host = mountExtensions({
|
||||
stored: { "extension.fixture.prefs": { open: true } },
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
stores: { prefs: Store.global(Prefs, { open: false }) },
|
||||
renderer: async () => ({
|
||||
default: (ctx: Context & { stores: { prefs: { value: { open: boolean } } } }) =>
|
||||
void seen.push(ctx.stores.prefs.value.open),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
const held = { status: host.status("fixture"), seen: [...seen] }
|
||||
host.release()
|
||||
await until(() => host.status("fixture") === "active")
|
||||
const outcome = { held, seen }
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ held: { status: "loading", seen: [] }, seen: [true] })
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
|
||||
@@ -242,7 +242,7 @@ test("keeps an older answer selected while scrolling within the interaction buff
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
await setupTimeline(page, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import path from "node:path"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
|
||||
|
||||
@@ -9,32 +9,16 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
const abandoned = Promise.withResolvers<void>()
|
||||
const main = { id: "ses_btw_sidebar", title: "Side question session" }
|
||||
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
|
||||
const ownerWarnings: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.text().includes("computations created outside a `createRoot` or `render`"))
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwSidebar",
|
||||
sessions: [main, other],
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
generations.push(input)
|
||||
|
||||
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
|
||||
|
||||
if (input.prompt.includes("left behind")) {
|
||||
await abandoned.promise
|
||||
|
||||
return { text: "This answer arrived after the user left." }
|
||||
}
|
||||
|
||||
await generated.promise
|
||||
|
||||
return {
|
||||
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
|
||||
}
|
||||
@@ -81,21 +65,8 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
// Leaving a session abandons its in-flight question, so it reads as failed on return.
|
||||
await editor.fill("/btw is this question left behind?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(other.id)}"]`).click()
|
||||
await expectSessionTitle(page, other.title)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
|
||||
abandoned.resolve()
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(panel).toHaveCount(0)
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
@@ -10,7 +10,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) =>
|
||||
["first.ts", "second.ts"].flatMap((path) => (path.includes(query) ? [fileNode(fixture.directory, path)] : [])),
|
||||
["first.ts", "second.ts"].filter((path) => path.includes(query)).map((path) => fileNode(fixture.directory, path)),
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
@@ -21,19 +21,15 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
|
||||
const fullWidth = async (card: typeof editor) => {
|
||||
const panel = (await files.boundingBox())!
|
||||
const box = await card.boundingBox()
|
||||
|
||||
return !!box && Math.abs(box.x - panel.x - 12) < 2 && Math.abs(box.width - panel.width + 24) < 2
|
||||
}
|
||||
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect.poll(() => fullWidth(editor)).toBe(true)
|
||||
}
|
||||
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
@@ -115,7 +111,6 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
],
|
||||
fileContent: async (path) => {
|
||||
if (path === "modified.ts") await held.promise
|
||||
|
||||
return `contents:${path}`
|
||||
},
|
||||
})
|
||||
@@ -144,13 +139,11 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
@@ -161,11 +154,9 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
|
||||
// A line comment is staged into the composer context without writing anything to the server.
|
||||
const writes: string[] = []
|
||||
|
||||
const recordWrite = (request: Request) => {
|
||||
if (request.method() !== "GET") writes.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
||||
}
|
||||
|
||||
page.on("request", recordWrite)
|
||||
const note = "Use the existing value instead"
|
||||
const commented = review.locator('[data-file="src/review.ts"]')
|
||||
@@ -186,12 +177,8 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
|
||||
const storedWrap = () =>
|
||||
page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.review.mobileDiff") ?? "{}").wrap,
|
||||
)
|
||||
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap)
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
@@ -227,7 +214,6 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
.poll(async () => {
|
||||
const button = (await openFile.boundingBox())!
|
||||
const summary = (await modified.locator('[data-slot="session-review-change-summary"]').boundingBox())!
|
||||
|
||||
return button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -235,14 +221,12 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
// Opening a changed file selects its tab at once, before a slower file finishes loading; reopening switches back.
|
||||
await review.locator('[data-file="added.ts"]').getByRole("button", { name: "added.ts", exact: true }).click()
|
||||
const selected = files.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { selected: true })
|
||||
|
||||
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(selected).toHaveText([file])
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
|
||||
if (file === "modified.ts") held.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
@@ -251,11 +235,9 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
@@ -269,13 +251,9 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
|
||||
if (dismissal === "drag") {
|
||||
const bounds = (await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox())!
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
@@ -284,7 +262,6 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
@@ -12,13 +12,11 @@ for (const view of ["desktop", "mobile"] as const) {
|
||||
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
|
||||
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
|
||||
const requests: { directory: string; provider?: string }[] = []
|
||||
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewWithoutGit",
|
||||
project: { vcs: undefined },
|
||||
onVcsInit: (input) => requests.push(input),
|
||||
})
|
||||
|
||||
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
|
||||
|
||||
@@ -37,7 +35,6 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewOpenFile",
|
||||
vcsDiff: [fileDiff("src/changed.ts")],
|
||||
@@ -52,7 +49,6 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
findFiles: (input) => {
|
||||
searches.push(input)
|
||||
|
||||
return input.query === "nested" ? [fileNode(directory, "src/nested.ts")] : []
|
||||
},
|
||||
seed: {
|
||||
@@ -94,8 +90,6 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
// SAFETY: the server's wire body for a missing file, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "FileNotFoundError", path: "README.md", message: "File not found: README.md" },
|
||||
}),
|
||||
)
|
||||
@@ -148,10 +142,9 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
.toBeLessThanOrEqual(1)
|
||||
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(0)
|
||||
await root.evaluate((element) => void (element.dataset.e2eProbe = "original"))
|
||||
|
||||
await root.evaluate((element) => ((element as HTMLElement & { e2eProbe?: string }).e2eProbe = "original"))
|
||||
const expectSidebarKept = async () => {
|
||||
expect(await root.evaluate((element) => element.dataset.e2eProbe)).toBe("original")
|
||||
expect(await root.evaluate((element) => (element as HTMLElement & { e2eProbe?: string }).e2eProbe)).toBe("original")
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
}
|
||||
|
||||
@@ -276,9 +269,7 @@ test("file tree expands Windows paths and scrolls long names in both directions"
|
||||
{ ...fileNode(directory, longPath), path: `frontend\\${longFilename}` },
|
||||
]
|
||||
}
|
||||
|
||||
if (path) return []
|
||||
|
||||
return [
|
||||
{ ...fileNode(directory, "frontend", "directory"), name: "", path: "frontend\\" },
|
||||
fileNode(directory, "README.md"),
|
||||
@@ -380,7 +371,6 @@ test("a restored file tab keeps the file tree on Files Changed", async ({ page }
|
||||
|
||||
test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
const file = { content: "first draft" }
|
||||
|
||||
const messages = [
|
||||
{ id: "msg_prompt", type: "user", text: "Write the shopping list", time: { created: 1 } },
|
||||
{
|
||||
@@ -392,7 +382,6 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
await openSession(page, {
|
||||
name: "ArtifactReopen",
|
||||
fileList: () => [],
|
||||
@@ -416,9 +405,6 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
/** What the image case's frame probe records on the window. */
|
||||
type ImageProbe = { problems: string[]; frames: number; running: boolean }
|
||||
|
||||
test("image files keep the review panel painted while loading", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 960, height: 900 })
|
||||
const directory = "C:/OpenCode/ReviewImage"
|
||||
@@ -432,16 +418,12 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
if (path !== image) return undefined
|
||||
read.resolve()
|
||||
await release.promise
|
||||
|
||||
return { type: "binary", content: "iVBORw0KGgo=", encoding: "base64", mimeType: "image/png" }
|
||||
},
|
||||
fileList: (path) => {
|
||||
if (!path) return [fileNode(directory, "assets", "directory"), fileNode(directory, "src", "directory")]
|
||||
|
||||
if (path === "assets") return [fileNode(directory, image)]
|
||||
|
||||
if (path === "src") return [fileNode(directory, "src/example.ts")]
|
||||
|
||||
return []
|
||||
},
|
||||
})
|
||||
@@ -450,37 +432,27 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
|
||||
// Records every painted frame from before the click until after the image read completes.
|
||||
await page.evaluate(() => {
|
||||
const probe: ImageProbe = { problems: [], frames: 0, running: true }
|
||||
|
||||
const probe = { problems: [] as string[], frames: 0, running: true }
|
||||
const sample = () => {
|
||||
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
|
||||
const rect = panel?.getBoundingClientRect()
|
||||
|
||||
const hit =
|
||||
rect?.width && rect.height ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null
|
||||
|
||||
const frame = [
|
||||
!panel?.checkVisibility() && "hidden",
|
||||
!panel?.textContent?.trim() && "blank",
|
||||
!(hit && document.querySelector("#review-panel")?.contains(hit)) && "outside the review panel",
|
||||
hit && getComputedStyle(hit).backgroundColor === "rgb(0, 0, 0)" && "black",
|
||||
].filter((problem) => problem !== false && problem !== null)
|
||||
|
||||
].filter((problem) => typeof problem === "string")
|
||||
probe.problems.push(...frame)
|
||||
probe.frames += 1
|
||||
|
||||
if (probe.running) requestAnimationFrame(sample)
|
||||
}
|
||||
|
||||
// The evaluates below read it.
|
||||
Object.assign(window, { e2eImageProbe: probe })
|
||||
;(window as Window & { e2eImageProbe?: typeof probe }).e2eImageProbe = probe
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
|
||||
const frames = () =>
|
||||
// SAFETY: the evaluate above stored the probe on the window before any frame is counted.
|
||||
page.evaluate(() => (window as Window & { e2eImageProbe?: ImageProbe }).e2eImageProbe!.frames)
|
||||
|
||||
page.evaluate(() => (window as Window & { e2eImageProbe?: { frames: number } }).e2eImageProbe!.frames)
|
||||
const response = page.waitForResponse((item) => item.url().includes(`/api/fs/read/${image}`))
|
||||
await page.getByRole("button", { name: /preview\.png/ }).click()
|
||||
await read.promise
|
||||
@@ -493,10 +465,8 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
await expect.poll(frames).toBeGreaterThan(loaded)
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
// SAFETY: the evaluate above stored the probe on the window.
|
||||
const probe = (window as Window & { e2eImageProbe?: ImageProbe }).e2eImageProbe!
|
||||
const probe = (window as Window & { e2eImageProbe?: { problems: string[]; running: boolean } }).e2eImageProbe!
|
||||
probe.running = false
|
||||
|
||||
return probe.problems
|
||||
}),
|
||||
).toEqual([])
|
||||
@@ -508,7 +478,6 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
{ id: "ses_review_state_b", title: "Beta review state" },
|
||||
{ id: "ses_review_state_c", title: "Gamma review state" },
|
||||
]
|
||||
|
||||
const directory = "C:/OpenCode/ReviewState"
|
||||
await openSession(page, {
|
||||
name: "ReviewState",
|
||||
@@ -524,13 +493,10 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
const panel = page.locator("#review-panel")
|
||||
const review = panel.locator("#session-side-panel-review-tab")
|
||||
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||
|
||||
const selectedTab = (name: string) =>
|
||||
expect(panel.getByRole("tab", { name, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const selectedFile = (file: string) =>
|
||||
expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
||||
|
||||
const switchSession = async (title: string) => {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
await expectSessionTitle(page, title)
|
||||
@@ -637,7 +603,6 @@ test("keeps the review state a session stored before extensions", async ({ page
|
||||
})
|
||||
const mode = (name: string) => page.getByRole("button", { name, exact: true })
|
||||
const changes = page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Changes" })
|
||||
|
||||
const trigger = (file: string) =>
|
||||
page
|
||||
.locator(`[data-component="session-review"] [data-file="${file}"]`)
|
||||
@@ -678,10 +643,8 @@ test("desktop review waits for the session's stored mode before it loads changes
|
||||
const working: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
|
||||
if (url.pathname.endsWith("/vcs/diff") && url.searchParams.get("mode") === "working") working.push(request.url())
|
||||
})
|
||||
|
||||
const desktop = (hold?: string) =>
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({
|
||||
server: SERVER,
|
||||
@@ -690,7 +653,6 @@ test("desktop review waits for the session's stored mode before it loads changes
|
||||
path: `/server/${base64Encode("sidecar")}/session/${id}`,
|
||||
...(hold && { hold }),
|
||||
})}`
|
||||
|
||||
const mode = (name: string) => page.getByRole("button", { name, exact: true })
|
||||
const file = page.locator('[data-slot="session-review-v2-file-name"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
@@ -724,36 +686,6 @@ test("desktop review waits for the session's stored mode before it loads changes
|
||||
expect(working).toEqual([])
|
||||
})
|
||||
|
||||
test("a chosen mode the session stops offering shows as Git, and comes back when it is offered again", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The mock server reads the branches on each request, so a reload sees the session's branch change.
|
||||
const vcs = { current: "feature", default: "dev" }
|
||||
await openSession(page, {
|
||||
name: "ReviewModeBack",
|
||||
vcs,
|
||||
vcsDiff: ({ mode }) => (mode === "branch" ? [fileDiff("beta.ts")] : [fileDiff("gamma.ts")]),
|
||||
})
|
||||
const mode = (name: string) => page.getByRole("button", { name, exact: true })
|
||||
const file = page.locator('[data-slot="session-review-v2-file-name"]')
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await mode("Git changes").click()
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
|
||||
// Back on the default branch, Branch is not offered.
|
||||
vcs.current = "dev"
|
||||
await page.reload()
|
||||
await expect(mode("Git changes")).toBeVisible()
|
||||
await expect(file).toHaveText("gamma.ts")
|
||||
|
||||
vcs.current = "feature"
|
||||
await page.reload()
|
||||
await expect(mode("Branch changes")).toBeVisible()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 900 })
|
||||
@@ -776,7 +708,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await panel.boundingBox())!
|
||||
|
||||
return (
|
||||
closed.x >= box.x &&
|
||||
closed.x + closed.width <= box.x + box.width &&
|
||||
@@ -788,7 +719,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
|
||||
return box ? box.y + box.height / 2 : undefined
|
||||
})
|
||||
.toBe(closed.y + closed.height / 2)
|
||||
@@ -801,9 +731,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
// Pause in the same task as each click so even the first painted state can be inspected.
|
||||
for (const opened of [true, false]) {
|
||||
await toggle.evaluate((element) => {
|
||||
// SAFETY: `toggle` is the review toggle, a button.
|
||||
const button = element as HTMLButtonElement
|
||||
button.click()
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
@@ -811,7 +739,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await expect(panel).toHaveAttribute("aria-hidden", String(!opened))
|
||||
|
||||
for (const progress of [0.25, 0.8]) await expectHeaderClearOfToggle(page, toggle, progress)
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
@@ -820,7 +747,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.forEach((animation) => animation.finish()),
|
||||
)
|
||||
}
|
||||
|
||||
await expect(panel).toBeHidden()
|
||||
})
|
||||
}
|
||||
@@ -838,7 +764,6 @@ async function statusInset(status: Locator, side: "left" | "right") {
|
||||
return status.evaluate((element, side) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
const box = element.getBoundingClientRect()
|
||||
|
||||
return side === "right" ? viewport.right - box.right : box.left - viewport.left
|
||||
}, side)
|
||||
}
|
||||
@@ -856,22 +781,18 @@ function filesystemEvent(directory: string, file: string, event: "add" | "change
|
||||
async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress: number) {
|
||||
const geometry = await page.locator('[data-slot="session-chat-panel"]').evaluate((chat, progress) => {
|
||||
const row = chat.parentElement!
|
||||
|
||||
const animations = row
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
|
||||
const width = animations.find(
|
||||
(animation) => animation instanceof CSSTransition && animation.transitionProperty === "width",
|
||||
)!
|
||||
|
||||
animations.forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = Number(width.effect!.getTiming().duration) * progress
|
||||
})
|
||||
const chatBounds = chat.getBoundingClientRect()
|
||||
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
row: row.getBoundingClientRect().width,
|
||||
panelWidth: panelBounds.width,
|
||||
@@ -883,11 +804,8 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
|
||||
panels: chatBounds.width + panelBounds.width + parseFloat(getComputedStyle(row).columnGap),
|
||||
}
|
||||
}, progress)
|
||||
|
||||
expect(geometry.gap).toBeCloseTo(8, 1)
|
||||
|
||||
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
|
||||
|
||||
if (progress === 0.25) {
|
||||
expect(geometry.contentOpacity).toBeGreaterThan(0)
|
||||
expect(geometry.contentOpacity).toBeLessThan(1)
|
||||
|
||||
@@ -10,8 +10,7 @@ for (const custom of [false, true]) {
|
||||
test(`summary toggle ${custom ? "custom" : "default"} shortcut follows the active session`, async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
|
||||
// The legacy command ID; the keybind migration renames it to `details.toggle`.
|
||||
// The legacy command ID; the keybind migration renames it to `summary.toggle`.
|
||||
if (custom) await seed(page, { settings: { keybinds: { "session.summary.toggle": "f8" } } })
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
@@ -26,7 +25,6 @@ for (const custom of [false, true]) {
|
||||
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
|
||||
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
|
||||
)
|
||||
|
||||
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(id)}"]`).click()
|
||||
const messages = id === fixture.sourceID ? fixture.expected.sourceMessageIDs : fixture.expected.targetMessageIDs
|
||||
@@ -43,18 +41,6 @@ for (const custom of [false, true]) {
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
|
||||
if (!custom) return
|
||||
|
||||
// The override moved, so storage keeps only its new id and Shortcuts lists no row titled with the old one.
|
||||
const keybinds = () => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "null")?.keybinds)
|
||||
await expect.poll(keybinds).toEqual({ "details.toggle": "f8" })
|
||||
await page.goto("/settings")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
|
||||
await expect(settings.getByText("Toggle summary", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("session.summary.toggle", { exact: true })).toHaveCount(0)
|
||||
expect(await keybinds()).toEqual({ "details.toggle": "f8" })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +74,7 @@ test("summary disclosures import legacy settings and persist in extension storag
|
||||
await expect(trigger).toBeFocused()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.details.prefs") ?? "null")),
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.summary.prefs") ?? "null")),
|
||||
)
|
||||
.toEqual({ projectExpanded: true, serverExpanded: false })
|
||||
|
||||
@@ -134,49 +120,6 @@ test("a session in a worktree subfolder names its worktree and lists cached work
|
||||
list.release()
|
||||
})
|
||||
|
||||
test("the details follow the routed session when it moves to another worktree", async ({ page }) => {
|
||||
const root = "C:/OpenCode/SmokeWorktrees"
|
||||
|
||||
const sessions = fixture.sessions.map((item) =>
|
||||
item.id === fixture.targetID ? { ...item, directory: `${root}/feature` } : { ...item },
|
||||
)
|
||||
|
||||
const mock = await mockStressTimeline(page, {
|
||||
sessions,
|
||||
worktrees: [
|
||||
{ directory: fixture.directory },
|
||||
{ directory: `${root}/feature`, strategy: "git" },
|
||||
{ directory: `${root}/other`, strategy: "git" },
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const location = (name: string) => summary.getByRole("button", { name, exact: true })
|
||||
await trigger.click()
|
||||
await expect(location("feature")).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
|
||||
// The server moves the session; the screen stays mounted while its extensions read the new worktree.
|
||||
const moved = sessions.find((item) => item.id === fixture.targetID)
|
||||
|
||||
if (moved) moved.directory = `${root}/other`
|
||||
await mock.push([
|
||||
{
|
||||
id: "evt_details_session_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: fixture.targetID, seq: 1, version: 1 },
|
||||
data: { sessionID: fixture.targetID, location: { directory: `${root}/other` }, projectID: fixture.project.id },
|
||||
},
|
||||
])
|
||||
await trigger.click()
|
||||
await expect(location("other")).toBeVisible()
|
||||
await expect(location("feature")).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`summary overlays the view and submenus follow ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
@@ -188,11 +131,9 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const warnings = ownerWarnings(page)
|
||||
|
||||
const row = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
|
||||
const composer = page.locator('[data-component="session-composer-dock"] > div')
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(row).toBeInViewport()
|
||||
@@ -204,7 +145,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(async () => {
|
||||
const message = (await row.boundingBox())!
|
||||
const input = (await composer.boundingBox())!
|
||||
|
||||
return Math.max(Math.abs(message.x - before.row.x), Math.abs(input.x - before.composer.x))
|
||||
})
|
||||
.toBeLessThan(1)
|
||||
@@ -212,7 +152,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(async () => {
|
||||
const message = (await row.boundingBox())!
|
||||
const details = (await summary.boundingBox())!
|
||||
|
||||
return Math.min(message.x + message.width, details.x + details.width) - Math.max(message.x, details.x)
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
@@ -221,12 +160,10 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
// Labels, including the truncated branch, stop 12px before the trailing indicator column.
|
||||
const workspace = summary.getByRole("button", { name: "Local repository", exact: true })
|
||||
|
||||
for (const [label, indicator] of [
|
||||
[text, workspace.locator(".session-summary-menu-indicator")],
|
||||
...["Local repository", "MCP", "Plugins", "Skills", "LSP"].map((name) => {
|
||||
const item = summary.getByRole("button", { name, exact: true })
|
||||
|
||||
return [item.locator(".session-summary-label"), item.locator(".session-summary-menu-indicator")]
|
||||
}),
|
||||
]) {
|
||||
@@ -234,12 +171,10 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(async () => {
|
||||
const start = (await label!.boundingBox())!
|
||||
const end = (await indicator!.boundingBox())!
|
||||
|
||||
return direction === "ltr" ? end.x - start.x - start.width : start.x - end.x - end.width
|
||||
})
|
||||
.toBeGreaterThanOrEqual(12)
|
||||
}
|
||||
|
||||
await expectAlignedWithHeader(page, direction)
|
||||
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
@@ -253,7 +188,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(async () => {
|
||||
const item = (await mcp.boundingBox())!
|
||||
const menu = (await submenu.boundingBox())!
|
||||
|
||||
return direction === "ltr" ? menu.x + menu.width <= item.x : menu.x >= item.x + item.width
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -275,7 +209,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(empty, { exact: true })).toBeVisible()
|
||||
await expect(submenu).toBeHidden()
|
||||
}
|
||||
|
||||
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
|
||||
await page.keyboard.press("Escape")
|
||||
@@ -291,7 +224,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
test("summary catalogs load, refresh while cached, and tell errors from empty", async ({ page }) => {
|
||||
const state = { fail: true, extra: false, empty: false }
|
||||
const pluginDirectories: string[] = []
|
||||
|
||||
const lsp = [
|
||||
{
|
||||
type: "document",
|
||||
@@ -304,7 +236,6 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
|
||||
},
|
||||
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
|
||||
]
|
||||
|
||||
await mockStressTimeline(page, {
|
||||
mcp: () => (state.empty ? [] : [{ name: "summary-mcp", status: { status: "connected" } }]),
|
||||
plugins: () =>
|
||||
@@ -349,9 +280,7 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
|
||||
(route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
pluginDirectories.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
|
||||
if (state.fail) return route.fulfill({ status: 500, headers: cors, json: { message: "Unavailable" } })
|
||||
|
||||
return route.fallback()
|
||||
},
|
||||
)
|
||||
@@ -408,7 +337,6 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
|
||||
|
||||
state.empty = true
|
||||
lsp.length = 0
|
||||
|
||||
for (const service of [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured" },
|
||||
@@ -419,7 +347,6 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
|
||||
await expect(menu(service.name).getByText(service.empty, { exact: true })).toBeVisible()
|
||||
await expectRefreshKeeps(page, service.name, service.path, service.empty)
|
||||
}
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
@@ -436,7 +363,6 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
const writes: { path: string; directory: string | null }[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
|
||||
if (request.method() !== "POST" || !url.pathname.startsWith("/api/experimental/mcp/")) return
|
||||
writes.push({ path: url.pathname, directory: url.searchParams.get("location[directory]") })
|
||||
})
|
||||
@@ -445,11 +371,9 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
|
||||
@@ -462,20 +386,14 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
|
||||
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
|
||||
|
||||
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
|
||||
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
|
||||
if (target === "keyboard") await expect(toggle).toBeFocused()
|
||||
expect(writes).toHaveLength(index + 1)
|
||||
expect(writes[index]).toEqual({
|
||||
@@ -493,7 +411,6 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
mcp: [{ name: "linear", integrationID: "linear-oauth", status: { status: "disabled" } }],
|
||||
onMcpAction: () => {
|
||||
state.connected = true
|
||||
|
||||
return { status: "needs_auth" }
|
||||
},
|
||||
integrations: [{ id: "linear-oauth", name: "Linear", methods: [{ id: "oauth", type: "oauth" }], connections: [] }],
|
||||
@@ -503,19 +420,16 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
if (request.method() === "POST" && new URL(request.url()).pathname.startsWith("/api/integration/"))
|
||||
attempts.push(request.url())
|
||||
})
|
||||
|
||||
// Only the resource catalog refresh after connecting is slow.
|
||||
const resources = await holdRoute(page, (url) => state.connected && url.pathname === "/api/mcp/resource", {
|
||||
method: "GET",
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
@@ -526,7 +440,6 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
} finally {
|
||||
resources.release()
|
||||
}
|
||||
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
|
||||
@@ -562,7 +475,6 @@ test.describe("remote configuration", () => {
|
||||
test("remote servers copy each service's configuration path", async ({ page }) => {
|
||||
const remote = "http://summary-remote.test:4096"
|
||||
const home = "/home/remote/.config/opencode"
|
||||
|
||||
const entries = [
|
||||
{ type: "document", path: `${home}/mcp.jsonc`, info: { mcp: { servers: {} } } },
|
||||
{ type: "document", path: `${home}/plugins.json`, info: { plugins: [] } },
|
||||
@@ -571,7 +483,6 @@ test.describe("remote configuration", () => {
|
||||
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
|
||||
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
|
||||
]
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
server: remote,
|
||||
sessions: fixture.sessions,
|
||||
@@ -601,18 +512,15 @@ test.describe("remote configuration", () => {
|
||||
await expect(tooltip).toHaveCount(0)
|
||||
await copy.hover()
|
||||
await expect(tooltip).toHaveText("Copy")
|
||||
|
||||
if (index === 0) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const icon = (await copy.locator("svg").boundingBox())!
|
||||
const tip = (await tooltip.boundingBox())!
|
||||
|
||||
return Math.abs(tip.x + tip.width / 2 - icon.x - icon.width / 2)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
await copy.click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(service.path)
|
||||
await expect(tooltip).toHaveText("Copied")
|
||||
@@ -625,11 +533,9 @@ test.describe("remote configuration", () => {
|
||||
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
|
||||
await trigger.click()
|
||||
await summary.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
|
||||
const copy = page
|
||||
.getByRole("dialog", { name: "Skills", exact: true })
|
||||
.getByRole("button", { name: "Copy configuration file path", exact: true })
|
||||
|
||||
await copy.click()
|
||||
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
|
||||
await expect(copy).toBeEnabled()
|
||||
@@ -656,7 +562,6 @@ test("long MCP lists stay in the viewport in a real RTL locale", async ({ page }
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = (await menu.boundingBox())!
|
||||
|
||||
return bounds.x >= 15 && bounds.y >= 15 && bounds.x + bounds.width <= 785 && bounds.y + bounds.height <= 585
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -674,7 +579,6 @@ function ownerWarnings(page: Page) {
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
@@ -685,7 +589,6 @@ async function expectAlignedWithHeader(page: Page, direction: "ltr" | "rtl") {
|
||||
.poll(async () => {
|
||||
const header = (await page.locator("[data-session-title]").boundingBox())!
|
||||
const panel = (await summary.boundingBox())!
|
||||
|
||||
return direction === "ltr"
|
||||
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
|
||||
: Math.abs(panel.x - header.x - 12)
|
||||
|
||||
@@ -30,7 +30,6 @@ for (const mode of ["failed", "stopped", "ready"] as const) {
|
||||
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
|
||||
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
|
||||
}
|
||||
|
||||
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
|
||||
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
|
||||
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
|
||||
@@ -65,33 +64,10 @@ test("adding a WSL server while the WSL extension is down shows that WSL is unav
|
||||
await expect(dialog.getByText("WSL is unavailable", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("adding an SSH server while the SSH extension is down fails instead of connecting forever", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: "/repo",
|
||||
project: project({ id: "proj_ssh_unavailable", directory: "/repo", name: "SSH project" }),
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1)
|
||||
await page.getByRole("checkbox", { name: "SSH extension" }).uncheck()
|
||||
|
||||
await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter")
|
||||
await page.getByRole("menuitem", { name: "Add SSH server", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toHaveText("Request failed")
|
||||
await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
test("an open session's terminal follows its WSL server to the endpoint it restarts on", async ({ page }) => {
|
||||
const restarted = "http://127.0.0.1:4098"
|
||||
const directory = "/home/ubuntu/project"
|
||||
const wsl = session({ id: "ses_wsl", directory, title: "WSL session" })
|
||||
|
||||
const config = {
|
||||
directory,
|
||||
project: project({ id: "proj_wsl", directory }),
|
||||
@@ -99,13 +75,11 @@ test("an open session's terminal follows its WSL server to the endpoint it resta
|
||||
sessions: [wsl],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
const servers = await mockServers(page, {
|
||||
[SERVER]: { ...config, sessions: [] },
|
||||
[REMOTE_SERVER]: { ...config, pty: { prefix: "pty_before" } },
|
||||
[restarted]: { ...config, pty: { prefix: "pty_after" } },
|
||||
})
|
||||
|
||||
const path = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}`
|
||||
await page.goto(
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", wsl: REMOTE_SERVER, restart: restarted, path })}`,
|
||||
@@ -133,7 +107,6 @@ test("an open session's terminal follows its WSL server to the endpoint it resta
|
||||
test("WSL session and draft tabs outlive the extension going away until the server is removed", async ({ page }) => {
|
||||
const directory = "/home/ubuntu/project"
|
||||
const wsl = session({ id: "ses_wsl_tabs", directory, title: "WSL tabs session" })
|
||||
|
||||
const config = {
|
||||
directory,
|
||||
project: project({ id: "proj_wsl_tabs", directory }),
|
||||
@@ -141,7 +114,6 @@ test("WSL session and draft tabs outlive the extension going away until the serv
|
||||
sessions: [wsl],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config })
|
||||
const href = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}`
|
||||
await page.goto(
|
||||
@@ -154,13 +126,11 @@ test("WSL session and draft tabs outlive the extension going away until the serv
|
||||
await editor.fill("keep this draft")
|
||||
await expect(editor).toHaveText("keep this draft")
|
||||
const tabs = page.locator("a[data-titlebar-tab-link]")
|
||||
|
||||
const expectTabs = async () => {
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(tabs.nth(0)).toHaveAttribute("href", href)
|
||||
await expect(tabs.nth(1)).toHaveAttribute("href", /^\/new-session\?draftId=/)
|
||||
}
|
||||
|
||||
await expectTabs()
|
||||
|
||||
const extension = page.getByRole("checkbox", { name: "WSL extension" })
|
||||
@@ -185,7 +155,6 @@ test("WSL session and draft tabs outlive the extension going away until the serv
|
||||
test("an SSH host that asks for sign-in again opens the dialog once per selected tab", async ({ page }) => {
|
||||
const directory = "/home/box/project"
|
||||
const box = session({ id: "ses_ssh_offer", directory, title: "SSH offer session" })
|
||||
|
||||
const config = {
|
||||
directory,
|
||||
project: project({ id: "proj_ssh_offer", directory }),
|
||||
@@ -193,7 +162,6 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected
|
||||
sessions: [box],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config })
|
||||
const path = `/server/${base64Encode("ssh:box")}/session/${box.id}`
|
||||
await page.goto(
|
||||
@@ -231,17 +199,14 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected
|
||||
test("an open session moves to the controller a new SSH sign-in creates", async ({ page }) => {
|
||||
const directory = "/home/box/project"
|
||||
const model = { id: "box-model", name: "Box Model" }
|
||||
|
||||
const box = session({
|
||||
id: "ses_ssh",
|
||||
directory,
|
||||
title: "SSH session",
|
||||
model: { id: model.id, providerID: "opencode" },
|
||||
})
|
||||
|
||||
const remote = { password: "ssh-1" }
|
||||
const prompts: unknown[] = []
|
||||
|
||||
const config = {
|
||||
directory,
|
||||
project: project({ id: "proj_ssh", directory }),
|
||||
@@ -249,7 +214,6 @@ test("an open session moves to the controller a new SSH sign-in creates", async
|
||||
sessions: [box],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
const servers = await mockServers(page, {
|
||||
[SERVER]: { ...config, sessions: [] },
|
||||
[REMOTE_SERVER]: {
|
||||
@@ -258,7 +222,6 @@ test("an open session moves to the controller a new SSH sign-in creates", async
|
||||
onPrompt: (input) => prompts.push(input.body.text),
|
||||
},
|
||||
})
|
||||
|
||||
const path = `/server/${base64Encode("ssh:box")}/session/${box.id}`
|
||||
await page.goto(
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", ssh: REMOTE_SERVER, path })}`,
|
||||
|
||||
@@ -13,13 +13,11 @@ import {
|
||||
} from "../utils/app"
|
||||
import { mockServers } from "../utils/mock-server"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode, mockWorkspace, openSession } from "../utils/workspace"
|
||||
import { mockWorkspace } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const a = { id: "ses_tab_a", title: "Tab A session" }
|
||||
|
||||
const b = { id: "ses_tab_b", title: "Tab B session" }
|
||||
|
||||
const c = { id: "ses_tab_c", title: "Tab C session" }
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
@@ -30,7 +28,6 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
|
||||
sessions: [a, b],
|
||||
seed: { tabs: [a.id, { draft: "draft_tab_width", directory: "C:/OpenCode/Tabs" }, b.id] },
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(a.id))
|
||||
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
@@ -39,7 +36,6 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
|
||||
.poll(() =>
|
||||
tabs.evaluateAll((tabs) => {
|
||||
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
|
||||
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
@@ -47,7 +43,6 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
|
||||
|
||||
const link = page.locator(`a[data-titlebar-tab-link][href="${sessionHref(b.id, workspace.server)}"]`)
|
||||
const box = await link.boundingBox()
|
||||
|
||||
if (!box) throw new Error("tab link has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
@@ -123,7 +118,6 @@ for (const row of [
|
||||
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
|
||||
if (row.tabLayout !== "vertical") return
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
@@ -162,13 +156,11 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
|
||||
const handle = sidebar.locator('[data-component="resize-handle"]')
|
||||
|
||||
for (const [offset, width] of [
|
||||
[-80, "180px"],
|
||||
[-200, "140px"],
|
||||
] as const) {
|
||||
const box = await handle.boundingBox()
|
||||
|
||||
if (!box) throw new Error("vertical tab resize handle has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
@@ -176,7 +168,6 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", width)
|
||||
}
|
||||
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const label = sidebar.getByRole("button", { name, exact: true }).getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
@@ -251,11 +242,8 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
|
||||
if (!path.startsWith("/api/")) return
|
||||
|
||||
if (request.method() === "GET") reads.push(path)
|
||||
|
||||
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
|
||||
})
|
||||
const state = { text: "Original fixture answer" }
|
||||
@@ -279,7 +267,6 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
lastProject: { local: fixture.directory },
|
||||
tabs: [fixture.sourceID, fixture.targetID, fixture.childID],
|
||||
})
|
||||
|
||||
const attention = Promise.all(
|
||||
[fixture.targetID, fixture.childID].flatMap((id) =>
|
||||
["permission", "form"].map((kind) =>
|
||||
@@ -287,7 +274,6 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
|
||||
@@ -306,12 +292,10 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
|
||||
state.text,
|
||||
)
|
||||
|
||||
for (const id of [fixture.sourceID, fixture.targetID]) {
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
|
||||
}
|
||||
|
||||
expect(
|
||||
reads.filter(
|
||||
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
|
||||
@@ -323,14 +307,12 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
|
||||
test("five loaded workspace tabs stay rendered and reactive through repeated switches", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
|
||||
const mock = await mockStressTimeline(page, {
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
@@ -347,7 +329,6 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
})
|
||||
|
||||
// Each worktree session resolves to its own location in the shared project (the mock echoes the requested one).
|
||||
await seed(page, {
|
||||
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
|
||||
@@ -361,7 +342,6 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${item.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
|
||||
const active = sessions[0]!
|
||||
await mock.push([
|
||||
{
|
||||
@@ -381,56 +361,6 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("each session tab shows its own file tab after a switch to a session in another folder", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/FolderSwitch"
|
||||
const alpha = { id: "ses_folderswitch_alpha", title: "Folder switch alpha" }
|
||||
const beta = { id: "ses_folderswitch_beta", title: "Folder switch beta" }
|
||||
const other = { id: "ses_folderswitch_other", title: "Folder switch other", directory: "C:/OpenCode/FolderOther" }
|
||||
await openSession(page, {
|
||||
name: "FolderSwitch",
|
||||
sessions: [alpha, beta, other],
|
||||
fileList: (path) => (path ? [] : ["greet.ts", "guide.md", "notes.txt"].map((file) => fileNode(directory, file))),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: { panes: Object.fromEntries([alpha, beta, other].map((item) => [item.id, { review: true }])) },
|
||||
})
|
||||
const panel = page.locator("#review-panel")
|
||||
|
||||
const shows = async (file: string) => {
|
||||
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
const visit = async (target: { id: string; title: string }) => {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(target.id)}"]`).click()
|
||||
await expectSessionTitle(page, target.title)
|
||||
}
|
||||
|
||||
const open = async (file: string) => {
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await panel.getByRole("button", { name: file, exact: true }).click()
|
||||
await shows(file)
|
||||
}
|
||||
|
||||
await open("greet.ts")
|
||||
await visit(other)
|
||||
await open("notes.txt")
|
||||
await visit(beta)
|
||||
await open("guide.md")
|
||||
|
||||
// The session screen stays mounted while the route moves between folders, and each tab reads its own folder.
|
||||
for (const [target, file] of [
|
||||
[alpha, "greet.ts"],
|
||||
[other, "notes.txt"],
|
||||
[beta, "guide.md"],
|
||||
[other, "notes.txt"],
|
||||
[alpha, "greet.ts"],
|
||||
] as const) {
|
||||
await visit(target)
|
||||
await shows(file)
|
||||
}
|
||||
})
|
||||
|
||||
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
|
||||
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
|
||||
@@ -454,7 +384,6 @@ async function twoServers(
|
||||
) {
|
||||
const config = (sessions: ReturnType<typeof session>[], id: string) => {
|
||||
const directory = String(sessions[0]!.directory)
|
||||
|
||||
return {
|
||||
directory,
|
||||
project: project({ id, directory }),
|
||||
@@ -464,7 +393,6 @@ async function twoServers(
|
||||
strictDirectory: true,
|
||||
}
|
||||
}
|
||||
|
||||
await mockServers(page, {
|
||||
[SERVER]: config(input.a, "proj_server_a"),
|
||||
[REMOTE_SERVER]: {
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import pkg from "../../package.json" with { type: "json" }
|
||||
import type { SeedInput } from "../utils/app"
|
||||
import { openHeldStart } from "../utils/direction"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { mockWorkspace } from "../utils/workspace"
|
||||
|
||||
// The web app's version, which What's New compares with the version last seen.
|
||||
const current = pkg.version
|
||||
|
||||
const SEEN = "opencode.global.dat:extension.updater.seen"
|
||||
|
||||
const RELEASE_NOTES = "opencode.global.dat:extension.updater.releaseNotes"
|
||||
|
||||
const cors = { "access-control-allow-origin": "*" }
|
||||
|
||||
const splitPanes = {
|
||||
title: "Split panes",
|
||||
description: "Work side by side.",
|
||||
media: { type: "image", src: "/split.png" },
|
||||
}
|
||||
|
||||
// Newest first. Since 1.9.1 the app shows five desktop highlights: the CLI group, the repeat and the sixth stay out.
|
||||
const changelog = {
|
||||
releases: [
|
||||
{
|
||||
tag: `v${current}`,
|
||||
highlights: [
|
||||
{
|
||||
source: "Desktop",
|
||||
items: [
|
||||
splitPanes,
|
||||
{
|
||||
title: "Voice input",
|
||||
shortDescription: "Talk to the agent.",
|
||||
media: { type: "VIDEO", url: "/voice.mp4" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ source: "cli", items: [{ title: "Terminal only", description: "Not in the desktop app." }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: "1.9.2",
|
||||
highlights: [
|
||||
{ source: "desktop", title: "Faster startup", description: "Opens in half the time." },
|
||||
{
|
||||
source: "desktop",
|
||||
items: [
|
||||
splitPanes,
|
||||
{ title: "Themes", description: "Pick a theme." },
|
||||
{ title: "Session tabs", description: "Keep sessions open." },
|
||||
{ title: "Sixth", description: "Past the limit." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ tag: "1.9.1", highlights: [{ source: "desktop", title: "Seen before", description: "Shown at 1.9.1." }] },
|
||||
],
|
||||
}
|
||||
|
||||
// The list form, whose one release in range has no desktop highlights.
|
||||
const cliOnly = [{ tag: current, highlights: [{ source: "cli", title: "Terminal only", description: "Not here." }] }]
|
||||
|
||||
// A user who last saw 1.9.1, stored where the app kept it before the updater extension owned What's New.
|
||||
const upgraded = { storage: { "highlights.v1": { version: "1.9.1" } } }
|
||||
|
||||
test("What's New shows the highlights since the version seen, pages to Get started, and is seen once shown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = await serveChangelog(page)
|
||||
await start(page, upgraded)
|
||||
const dialog = page.getByRole("dialog")
|
||||
const title = dialog.getByRole("heading", { level: 1 })
|
||||
const next = dialog.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(title).toHaveText("Split panes")
|
||||
await expect(dialog.getByText("Work side by side.", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("img", { name: "Split panes", exact: true })).toHaveAttribute("src", "/split.png")
|
||||
// Seen as soon as it shows, so however it closes, the next start shows nothing.
|
||||
await expect.poll(() => stored(page, SEEN)).toEqual({ version: current })
|
||||
|
||||
await next.click()
|
||||
await expect(title).toHaveText("Voice input")
|
||||
await expect(dialog.getByText("Talk to the agent.", { exact: true })).toBeVisible()
|
||||
await expect(dialog.locator("video")).toHaveAttribute("src", "/voice.mp4")
|
||||
await page.keyboard.press("ArrowLeft")
|
||||
await expect(title).toHaveText("Split panes")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(title).toHaveText("Voice input")
|
||||
|
||||
await next.click()
|
||||
await expect(title).toHaveText("Faster startup")
|
||||
await expect(dialog.locator("img, video")).toHaveCount(0)
|
||||
await next.click()
|
||||
await expect(title).toHaveText("Themes")
|
||||
await next.click()
|
||||
await expect(title).toHaveText("Session tabs")
|
||||
await expect(next).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Get started", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("Don't show these in the future turns release notes off and closes What's New", async ({ page }) => {
|
||||
await serveChangelog(page)
|
||||
await start(page, upgraded)
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
|
||||
await dialog.getByRole("button", { name: "Don't show these in the future", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect.poll(() => stored(page, RELEASE_NOTES)).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({ page }) => {
|
||||
const held = Promise.withResolvers<void>()
|
||||
await serveChangelog(page, changelog, held.promise)
|
||||
await mockWorkspace(page, { name: "Whats New", sessions: [], seed: upgraded })
|
||||
const app = await openHeldStart(page, "/settings")
|
||||
await app.start()
|
||||
held.resolve()
|
||||
// What's New marks the release seen as it opens: after attachment, while an extension still holds the routes.
|
||||
await expect.poll(() => stored(page, SEEN)).toEqual({ version: "test" })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
const focused = () => dialog.evaluate((element) => element.contains(document.activeElement))
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByTestId("settings-screen")).toHaveCount(0)
|
||||
await app.finish()
|
||||
await expect(page.getByTestId("settings-screen")).toBeAttached()
|
||||
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
|
||||
await expect.poll(focused).toBe(true)
|
||||
|
||||
for (const key of ["Tab", "Tab", "Tab", "Tab", "Shift+Tab"]) {
|
||||
await page.keyboard.press(key)
|
||||
await expect.poll(focused).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("a failed changelog request leaves What's New for the next start", async ({ page }) => {
|
||||
const failed = Promise.withResolvers<void>()
|
||||
const requests: string[] = []
|
||||
|
||||
await page.route("https://opencode.ai/changelog.json", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
|
||||
if (requests.length > 1) return route.fulfill({ headers: cors, json: changelog })
|
||||
|
||||
await route.fulfill({ status: 503, headers: cors })
|
||||
failed.resolve()
|
||||
})
|
||||
|
||||
await start(page, upgraded)
|
||||
await failed.promise
|
||||
expect(await stored(page, SEEN)).toEqual({ version: "1.9.1" })
|
||||
await page.reload()
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { level: 1 })).toHaveText("Split panes")
|
||||
})
|
||||
|
||||
// Each start ends with the current version seen and no dialog; only a release in range is worth a changelog request.
|
||||
for (const row of [
|
||||
{ name: "the first run only remembers the version", seed: {}, requests: 0 },
|
||||
{
|
||||
name: "a version seen before, stored as highlights.v1, shows nothing",
|
||||
seed: { storage: { "highlights.v1": { version: current } } },
|
||||
requests: 0,
|
||||
},
|
||||
{
|
||||
name: "release notes turned off before, in settings.v3, only remember the version",
|
||||
seed: { ...upgraded, settings: { general: { releaseNotes: false } } },
|
||||
requests: 0,
|
||||
},
|
||||
{
|
||||
name: "a changelog without desktop highlights since the version seen only remembers the version",
|
||||
seed: upgraded,
|
||||
changelog: cliOnly,
|
||||
requests: 1,
|
||||
},
|
||||
]) {
|
||||
test(`What's New: ${row.name}`, async ({ page }) => {
|
||||
const requests = await serveChangelog(page, row.changelog)
|
||||
await start(page, row.seed)
|
||||
await expect.poll(() => stored(page, SEEN)).toEqual({ version: current })
|
||||
expect(requests).toHaveLength(row.requests)
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
/** Serves this changelog and records each request for it. */
|
||||
async function serveChangelog(page: Page, body: typeof changelog | typeof cliOnly = changelog, held?: Promise<void>) {
|
||||
const requests: string[] = []
|
||||
|
||||
await page.route("https://opencode.ai/changelog.json", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await held
|
||||
|
||||
return route.fulfill({ headers: cors, json: body })
|
||||
})
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
/** Opens Home over one empty project, with this storage, and waits until the window renders. */
|
||||
async function start(page: Page, seed: SeedInput) {
|
||||
await mockWorkspace(page, { name: "Whats New", sessions: [], seed })
|
||||
await page.goto("/")
|
||||
await expectAppVisible(page.getByRole("button", { name: "Settings", exact: true }))
|
||||
}
|
||||
|
||||
function stored(page: Page, key: string) {
|
||||
return page.evaluate((name) => localStorage.getItem(name), key).then((raw) => JSON.parse(raw ?? "null"))
|
||||
}
|
||||
@@ -1,95 +1,28 @@
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface, preloadRoute } from "../../src/app"
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
|
||||
import { PlatformProvider } from "../../src/runtime/platform/platform"
|
||||
import { createWebPlatform } from "../../src/runtime/platform/web"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { Command } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "../../src/runtime/extension/host"
|
||||
import { useCommand } from "../../src/shell/commands/command"
|
||||
|
||||
/**
|
||||
* Mounts the app at a route. With `held`, the window's providers and extensions start at once and the app interface
|
||||
* mounts only when "Start app" is pressed, over its preloaded route, as the desktop shell mounts it once its server is up.
|
||||
* One extension's load also waits for "Finish startup", so the attachment can mount before the routes render.
|
||||
*/
|
||||
export function mount(input: { server: string; route: string; direction: Direction; held?: boolean }) {
|
||||
export function mount(input: { server: string; route: string; direction: Direction }) {
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: input.route, replace: true, scroll: false })
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
|
||||
const [started, setStarted] = createSignal(!input.held)
|
||||
const [finished, setFinished] = createSignal(false)
|
||||
const held = Promise.withResolvers<void>()
|
||||
|
||||
function StartupGate() {
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
|
||||
// ExtensionCommands publishes before ready, proving the attachment mounted even while the routes wait.
|
||||
const registered = () =>
|
||||
host
|
||||
.items(Command)
|
||||
.some((item) => command.options.some((option) => option.id === `${item.extension}.${item.value.id}`))
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={!registered()}
|
||||
onClick={() => {
|
||||
held.resolve()
|
||||
setFinished(true)
|
||||
}}
|
||||
>
|
||||
Finish startup
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectedApp() {
|
||||
const language = useLanguage()
|
||||
language.setDirection(input.direction)
|
||||
|
||||
return (
|
||||
<AppInterface
|
||||
servers={[server]}
|
||||
defaultServer={ServerConnection.key(server)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
router={(props) => <MemoryRouter {...props} history={history} />}
|
||||
>
|
||||
<Show when={input.held && !finished()}>
|
||||
<StartupGate />
|
||||
</Show>
|
||||
</AppInterface>
|
||||
)
|
||||
}
|
||||
|
||||
function Startup() {
|
||||
const host = useExtensionHost()
|
||||
const definition = host.definitions().find((item) => item.renderer)
|
||||
const load = definition?.renderer
|
||||
|
||||
// Hold a real extension through the public reload API before the startup gate first settles.
|
||||
if (input.held && definition && load)
|
||||
host.reload(definition.id, {
|
||||
...definition,
|
||||
renderer: async () => {
|
||||
await held.promise
|
||||
|
||||
return load()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={started()}
|
||||
fallback={<button onClick={() => void preloadRoute(input.route).then(() => setStarted(true))}>Start app</button>}
|
||||
>
|
||||
<DirectedApp />
|
||||
</Show>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,7 +30,7 @@ export function mount(input: { server: string; route: string; direction: Directi
|
||||
() => (
|
||||
<PlatformProvider value={createWebPlatform("test").platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
<Startup />
|
||||
<DirectedApp />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -14,12 +14,7 @@
|
||||
<script type="module">
|
||||
import { mount } from "./app-direction.fixture.tsx"
|
||||
const query = new URLSearchParams(location.search)
|
||||
mount({
|
||||
server: query.get("server"),
|
||||
route: query.get("route"),
|
||||
direction: query.get("direction"),
|
||||
held: query.has("held"),
|
||||
})
|
||||
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { SERVER } from "./app"
|
||||
|
||||
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
|
||||
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server: SERVER, route, direction })}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the app at a route as the desktop shell starts it: the window's extensions run at once, and the app interface
|
||||
* mounts over the preloaded route only when `start` runs, as it does once the desktop's server is up. One extension
|
||||
* stays loading until `finish` runs; commands are published meanwhile, but the routes wait.
|
||||
*/
|
||||
export async function openHeldStart(page: Page, route: string) {
|
||||
await page.goto(
|
||||
`/e2e/utils/app-direction.html?${new URLSearchParams({ server: SERVER, route, direction: "ltr", held: "" })}`,
|
||||
)
|
||||
const start = page.getByRole("button", { name: "Start app", exact: true, includeHidden: true })
|
||||
await expect(start).toBeVisible()
|
||||
|
||||
return {
|
||||
// A modal open meanwhile hides the page outside it, so the button is found and clicked past the modal.
|
||||
start: async () => {
|
||||
await start.dispatchEvent("click")
|
||||
await expect(page.getByRole("button", { name: "Finish startup", exact: true, includeHidden: true })).toBeEnabled()
|
||||
},
|
||||
finish: () => page.getByRole("button", { name: "Finish startup", exact: true, includeHidden: true }).dispatchEvent("click"),
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,6 @@ import { PlatformProvider, type Platform } from "../../src/runtime/platform/plat
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { useExtensionServers } from "../../src/runtime/extension/servers"
|
||||
|
||||
/** The input of a WSL or SSH method this fixture answers. */
|
||||
type FixtureInput = { id?: string; name?: string }
|
||||
|
||||
/** A main-side method: SSH `start` answers with the state revision, the others with nothing. */
|
||||
type FixtureMethod = (input: FixtureInput) => number | void
|
||||
|
||||
// A desktop window. `wsl` is the Ubuntu server's endpoint (default `server`); updating OpenCode restarts it on `restart`
|
||||
// (default `wsl`). `ssh` adds a saved SSH server `box`, ready on that endpoint with password `ssh-1`; each connect
|
||||
// brings up a new remote server with the next password (`ssh-2`, ...). `storage=async` stores in localStorage behind
|
||||
@@ -31,28 +25,23 @@ export function mount(input: {
|
||||
hold?: string | null
|
||||
}) {
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: input.path ?? "/settings", replace: true, scroll: false })
|
||||
const endpoint = { url: input.wsl ?? input.server }
|
||||
const ready = () => ({ kind: "ready" as const, url: endpoint.url, password: null })
|
||||
const held = Promise.withResolvers<void>()
|
||||
|
||||
const storage = (name?: string) => {
|
||||
const item = (key: string) => (name ? `${name}:${key}` : key)
|
||||
|
||||
return {
|
||||
getItem: async (key: string) => {
|
||||
if (input.hold && key.includes(input.hold)) await held.promise
|
||||
|
||||
return localStorage.getItem(item(key))
|
||||
},
|
||||
setItem: async (key: string, value: string) => localStorage.setItem(item(key), value),
|
||||
removeItem: async (key: string) => localStorage.removeItem(item(key)),
|
||||
}
|
||||
}
|
||||
|
||||
render(() => {
|
||||
const [store, setStore] = createStore<{
|
||||
calls: string[]
|
||||
@@ -88,13 +77,12 @@ export function mount(input: {
|
||||
servers: [
|
||||
{
|
||||
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
|
||||
runtime: (
|
||||
{
|
||||
ready: ready(),
|
||||
failed: { kind: "failed", message: "WSL failed to start" },
|
||||
stopped: { kind: "stopped" },
|
||||
} satisfies Record<typeof input.mode, WslServerRuntime>
|
||||
)[input.mode],
|
||||
runtime:
|
||||
input.mode === "ready"
|
||||
? ready()
|
||||
: input.mode === "failed"
|
||||
? { kind: "failed", message: "WSL failed to start" }
|
||||
: { kind: "stopped" },
|
||||
},
|
||||
],
|
||||
opencodeChecks: {
|
||||
@@ -109,93 +97,76 @@ export function mount(input: {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// The main-process WSL and SSH extensions, as the extension bridge sees them.
|
||||
const listeners = new Set<(message: BridgeMessage) => void>()
|
||||
const snapshot = (ipc: string) => structuredClone(unwrap(ipc === "ssh" ? store.ssh : store.state))
|
||||
|
||||
const publish = (ipc: string) =>
|
||||
listeners.forEach((listener) => listener({ type: "state", ipc, state: snapshot(ipc) }))
|
||||
|
||||
const snapshot = (remote: string) => structuredClone(unwrap(remote === "ssh" ? store.ssh : store.state))
|
||||
const publish = (remote: string) =>
|
||||
listeners.forEach((listener) => listener({ type: "state", remote, state: snapshot(remote) }))
|
||||
// The contract state is deeply readonly, so each action replaces the changed branch.
|
||||
const setRuntime = (id: string | undefined, runtime: WslServerRuntime) =>
|
||||
setStore("state", (state) => ({
|
||||
servers: state.servers.map((server) => (server.config.id === id ? { ...server, runtime } : server)),
|
||||
}))
|
||||
|
||||
const setSsh = (item: Partial<SshItem>) =>
|
||||
setStore("ssh", (state) => ({
|
||||
revision: state.revision + 1,
|
||||
servers: state.servers.map((server) => ({ ...server, ...item })),
|
||||
}))
|
||||
|
||||
const wsl = {
|
||||
// Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint.
|
||||
installOpencode(value) {
|
||||
const name = value.name ?? ""
|
||||
const id = store.state.servers.find((server) => server.config.distro === name)?.config.id
|
||||
setStore("calls", (calls) => [...calls, `update:${value.name}`])
|
||||
setRuntime(id, { kind: "stopped" })
|
||||
publish("wsl")
|
||||
setStore("state", (state) => ({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true },
|
||||
},
|
||||
}))
|
||||
endpoint.url = input.restart ?? endpoint.url
|
||||
setRuntime(id, ready())
|
||||
const methods: Record<string, Record<string, (input: { id?: string; name?: string }) => unknown>> = {
|
||||
wsl: {
|
||||
// Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint.
|
||||
installOpencode(value) {
|
||||
const name = value.name ?? ""
|
||||
const id = store.state.servers.find((server) => server.config.distro === name)?.config.id
|
||||
setStore("calls", (calls) => [...calls, `update:${value.name}`])
|
||||
setRuntime(id, { kind: "stopped" })
|
||||
publish("wsl")
|
||||
setStore("state", (state) => ({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true },
|
||||
},
|
||||
}))
|
||||
endpoint.url = input.restart ?? endpoint.url
|
||||
setRuntime(id, ready())
|
||||
},
|
||||
startServer(value) {
|
||||
setStore("calls", (calls) => [...calls, `start:${value.id}`])
|
||||
setRuntime(value.id, ready())
|
||||
},
|
||||
removeServer(value) {
|
||||
setStore("calls", (calls) => [...calls, `remove:${value.id}`])
|
||||
setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) }))
|
||||
},
|
||||
},
|
||||
startServer(value) {
|
||||
setStore("calls", (calls) => [...calls, `start:${value.id}`])
|
||||
setRuntime(value.id, ready())
|
||||
ssh: {
|
||||
// Like main: a connect opens a new tunnel to a remote server with a new password.
|
||||
start() {
|
||||
setStore("connects", (count) => count + 1)
|
||||
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } })
|
||||
return store.ssh.revision
|
||||
},
|
||||
},
|
||||
removeServer(value) {
|
||||
setStore("calls", (calls) => [...calls, `remove:${value.id}`])
|
||||
setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) }))
|
||||
},
|
||||
} satisfies Readonly<Record<string, FixtureMethod>>
|
||||
|
||||
const ssh = {
|
||||
// Like main: a connect opens a new tunnel to a remote server with a new password.
|
||||
start() {
|
||||
setStore("connects", (count) => count + 1)
|
||||
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } })
|
||||
|
||||
return store.ssh.revision
|
||||
},
|
||||
} satisfies Readonly<Record<string, FixtureMethod>>
|
||||
|
||||
const methods = new Map<string, Readonly<Record<string, FixtureMethod>>>([
|
||||
["wsl", wsl],
|
||||
["ssh", ssh],
|
||||
])
|
||||
|
||||
}
|
||||
const bridge: Bridge = {
|
||||
packaged: false,
|
||||
async call(request) {
|
||||
const method = methods.get(request.ipc)?.[request.method]
|
||||
|
||||
const method = methods[request.remote]?.[request.method]
|
||||
if (!method) throw new Error("Unexpected fixture action")
|
||||
// SAFETY: every WSL and SSH method the fixture answers takes a struct of these optional string fields.
|
||||
const result = method(request.input as FixtureInput)
|
||||
publish(request.ipc)
|
||||
|
||||
const result = method(request.input as { id?: string; name?: string })
|
||||
publish(request.remote)
|
||||
return result ?? null
|
||||
},
|
||||
async subscribe(ipc) {
|
||||
if (ipc === "wsl" || ipc === "ssh") return { available: true, state: snapshot(ipc) }
|
||||
|
||||
async subscribe(remote) {
|
||||
if (remote === "wsl" || remote === "ssh") return { available: true, state: snapshot(remote) }
|
||||
return { available: false }
|
||||
},
|
||||
on(listener) {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
embed: () => undefined,
|
||||
surface: () => undefined,
|
||||
capture: async () => undefined,
|
||||
runMenubarItem: () => undefined,
|
||||
menubar: () => undefined,
|
||||
configure: () => undefined,
|
||||
manager: {
|
||||
list: async () => [],
|
||||
@@ -208,11 +179,9 @@ export function mount(input: {
|
||||
asset: () => "",
|
||||
},
|
||||
}
|
||||
|
||||
const unused = async () => {
|
||||
throw new Error("Unexpected fixture action")
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "desktop",
|
||||
os: "windows",
|
||||
@@ -222,18 +191,14 @@ export function mount(input: {
|
||||
notify: async () => undefined,
|
||||
restart: unused,
|
||||
extensions: bridge,
|
||||
...(input.storage === "async" ? { storage } : {}),
|
||||
}
|
||||
|
||||
if (input.storage === "async") platform.storage = storage
|
||||
|
||||
function Interface() {
|
||||
const extensions = useExtensionServers()
|
||||
|
||||
const servers = createMemo<ServerConnection.Any[]>(() => [
|
||||
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
|
||||
...extensions.list(),
|
||||
])
|
||||
|
||||
return (
|
||||
<Show when={extensions.ready()}>
|
||||
<AppInterface
|
||||
@@ -244,7 +209,6 @@ export function mount(input: {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
@@ -257,27 +221,12 @@ export function mount(input: {
|
||||
onChange={(event) => {
|
||||
const available = event.currentTarget.checked
|
||||
setStore("available", available)
|
||||
listeners.forEach((listener) => listener({ type: "available", ipc: "wsl", available }))
|
||||
|
||||
listeners.forEach((listener) => listener({ type: "available", remote: "wsl", available }))
|
||||
if (available) publish("wsl")
|
||||
}}
|
||||
/>
|
||||
WSL extension
|
||||
</label>
|
||||
{/* The SSH extension's main side going away and coming back. */}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
onChange={(event) => {
|
||||
const available = event.currentTarget.checked
|
||||
listeners.forEach((listener) => listener({ type: "available", ipc: "ssh", available }))
|
||||
|
||||
if (available) publish("ssh")
|
||||
}}
|
||||
/>
|
||||
SSH extension
|
||||
</label>
|
||||
{/* The SSH tunnel dropping, which only main notices. */}
|
||||
<Show when={input.ssh}>
|
||||
<button
|
||||
|
||||
@@ -9,25 +9,21 @@ import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
// The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output.
|
||||
export function mount(input: { server: string }) {
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
render(() => {
|
||||
const [store, setStore] = createStore<{ actions: string[] }>({ actions: [] })
|
||||
|
||||
const [store, setStore] = createStore({ actions: [] as string[] })
|
||||
const unused = async () => {
|
||||
throw new Error("Unexpected fixture action")
|
||||
}
|
||||
|
||||
// No main-process extensions run in this fixture.
|
||||
const bridge: Bridge = {
|
||||
packaged: false,
|
||||
call: unused,
|
||||
subscribe: async () => ({ available: false }),
|
||||
on: () => () => undefined,
|
||||
embed: () => undefined,
|
||||
surface: () => undefined,
|
||||
capture: async () => undefined,
|
||||
runMenubarItem: () => undefined,
|
||||
menubar: () => undefined,
|
||||
configure: () => undefined,
|
||||
manager: {
|
||||
list: async () => [],
|
||||
@@ -40,7 +36,6 @@ export function mount(input: { server: string }) {
|
||||
asset: () => "",
|
||||
},
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "desktop",
|
||||
os: "windows",
|
||||
@@ -52,7 +47,6 @@ export function mount(input: { server: string }) {
|
||||
extensions: bridge,
|
||||
runDesktopMenuAction: (action) => setStore("actions", (actions) => [...actions, action]),
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
import { GlobalProvider } from "@/runtime/server/runtime"
|
||||
import { HighlightsProvider } from "@/shell/updates/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
@@ -31,7 +32,6 @@ function QueryProvider(props: ParentProps) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export function AppBaseProviders(
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
void import("@sentry/solid").then(({ captureException }) => captureException(error))
|
||||
|
||||
return <ErrorPage error={error} />
|
||||
}}
|
||||
>
|
||||
@@ -100,8 +99,10 @@ export function AppInterface(props: {
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
|
||||
@@ -31,13 +31,11 @@ import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type {
|
||||
ComposerAgentPart,
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerFilePart,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
ComposerSkillPart,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
@@ -54,17 +52,11 @@ export type {
|
||||
} from "../types"
|
||||
|
||||
export type ComposerMode = "normal" | "shell"
|
||||
|
||||
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
|
||||
|
||||
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
|
||||
|
||||
const COMPOSER_SUGGESTION_ROW_PEEK = 18
|
||||
|
||||
const COMPOSER_SUGGESTION_TOP_PADDING = 8
|
||||
|
||||
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
|
||||
|
||||
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
|
||||
|
||||
export type ComposerEditorProps = {
|
||||
@@ -91,7 +83,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
let controlsViewport!: HTMLDivElement
|
||||
let controlsContent!: HTMLDivElement
|
||||
const [overflow, setOverflow] = createStore({ start: false, end: false })
|
||||
|
||||
const updateOverflow = () => {
|
||||
const offset = Math.abs(controlsViewport.scrollLeft)
|
||||
setOverflow({
|
||||
@@ -99,7 +90,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(updateOverflow)
|
||||
observer.observe(controlsViewport)
|
||||
@@ -108,14 +98,11 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
let localInput = false
|
||||
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
props.controller.onCursor(composerCursor(editor))
|
||||
}
|
||||
|
||||
const mode = createMemo(() => state.mode)
|
||||
|
||||
const buttons = createMemo(() => ({
|
||||
opacity: mode() === "normal" ? 1 : 0,
|
||||
"pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const),
|
||||
@@ -124,31 +111,14 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const parts = props.controller.parts()
|
||||
|
||||
if (!editor) return
|
||||
|
||||
if (localInput) {
|
||||
localInput = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderComposerEditor(editor, parts)
|
||||
})
|
||||
|
||||
// Kept out of JSX: an inline ternary prop compiles to a memo created on every read, and the popover reads
|
||||
// search from a ResizeObserver callback, where that memo has no owner and is never disposed.
|
||||
const search = () =>
|
||||
state.popover.type === "command-menu"
|
||||
? {
|
||||
value: state.popover.query,
|
||||
label: i18n.t("ui.promptInput.commands"),
|
||||
placeholder: "/",
|
||||
onValueChange: props.controller.setQuery,
|
||||
onKeyDown: props.controller.onKeyDown,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
|
||||
<input
|
||||
@@ -158,7 +128,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
class="hidden"
|
||||
onChange={(event) => {
|
||||
const list = event.currentTarget.files
|
||||
|
||||
if (list) props.controller.addAttachments(Array.from(list))
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
@@ -169,7 +138,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
items={props.controller.suggestions()}
|
||||
boundary={props.suggestionBoundary}
|
||||
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
|
||||
search={search()}
|
||||
search={
|
||||
state.popover.type === "command-menu"
|
||||
? {
|
||||
value: state.popover.query,
|
||||
label: i18n.t("ui.promptInput.commands"),
|
||||
placeholder: "/",
|
||||
onValueChange: props.controller.setQuery,
|
||||
onKeyDown: props.controller.onKeyDown,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
|
||||
onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
|
||||
/>
|
||||
@@ -183,7 +162,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!props.disabled) props.controller.submit()
|
||||
}}
|
||||
onDragEnter={props.controller.onDragEnter}
|
||||
@@ -246,16 +224,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onKeyDown={(event) => {
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
|
||||
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
|
||||
if (view.submit.queue?.editFirst()) event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
|
||||
if (event.repeat) return
|
||||
props.controller.submit(mod ? { alternate: true } : undefined)
|
||||
}
|
||||
@@ -267,17 +241,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
// Programmatic multiline insertion does not reliably reveal the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
|
||||
|
||||
if (!editor.contains(selection.anchorNode)) return
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
|
||||
if (!caret.height) return
|
||||
const bounds = viewport.getBoundingClientRect()
|
||||
|
||||
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
|
||||
|
||||
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
|
||||
})
|
||||
}}
|
||||
@@ -406,7 +375,6 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (isAttachment(part)) return []
|
||||
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
@@ -416,26 +384,19 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
mention.style.unicodeBidi = "isolate"
|
||||
mention.dataset.mention =
|
||||
part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
|
||||
|
||||
if (part.type === "agent") mention.dataset.name = part.name
|
||||
|
||||
if (part.type === "skill") {
|
||||
mention.dataset.id = part.id
|
||||
mention.dataset.name = part.name
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
mention.dataset.path = part.path
|
||||
|
||||
if (part.mime) mention.dataset.mime = part.mime
|
||||
|
||||
if (part.filename) mention.dataset.filename = part.filename
|
||||
}
|
||||
|
||||
return [mention]
|
||||
}),
|
||||
)
|
||||
|
||||
if (!active) return
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
@@ -456,111 +417,85 @@ function parseComposerEditor(editor: HTMLDivElement) {
|
||||
position += buffer.length
|
||||
buffer = ""
|
||||
}
|
||||
|
||||
const mention = (element: HTMLElement) => {
|
||||
flush()
|
||||
const content = element.textContent ?? ""
|
||||
const original = mentionParts.get(element)
|
||||
|
||||
if (element.dataset.mention === "agent") {
|
||||
const agent: ComposerAgentPart = {
|
||||
parts.push({
|
||||
...(original?.type === "agent" ? original : {}),
|
||||
type: "agent",
|
||||
name: element.dataset.name ?? content.slice(1),
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
|
||||
parts.push(original?.type === "agent" ? { ...original, ...agent } : agent)
|
||||
})
|
||||
position += content.length
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (element.dataset.mention === "skill") {
|
||||
const skill: ComposerSkillPart = {
|
||||
parts.push({
|
||||
...(original?.type === "skill" ? original : {}),
|
||||
type: "skill",
|
||||
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
|
||||
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
|
||||
parts.push(original?.type === "skill" ? { ...original, ...skill } : skill)
|
||||
})
|
||||
position += content.length
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const parsed: ComposerFilePart = {
|
||||
parts.push({
|
||||
...(original?.type === "file" ? original : {}),
|
||||
type: "file",
|
||||
path: element.dataset.path ?? content.slice(1),
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
|
||||
const file: ComposerFilePart = original?.type === "file" ? { ...original, ...parsed } : parsed
|
||||
|
||||
if (element.dataset.mime) file.mime = element.dataset.mime
|
||||
|
||||
if (element.dataset.filename) file.filename = element.dataset.filename
|
||||
parts.push(file)
|
||||
...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
|
||||
...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
|
||||
})
|
||||
position += content.length
|
||||
}
|
||||
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
buffer += node.textContent ?? ""
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
|
||||
if (node.dataset.mention) {
|
||||
mention(node)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (node.tagName === "BR") {
|
||||
buffer += "\n"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Array.from(node.childNodes).forEach(visit)
|
||||
}
|
||||
|
||||
Array.from(editor.childNodes).forEach((node, index, nodes) => {
|
||||
visit(node)
|
||||
|
||||
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
|
||||
})
|
||||
flush()
|
||||
|
||||
if (
|
||||
parts.every((part) => part.type === "text") &&
|
||||
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
|
||||
) {
|
||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
if (parts.length > 0) return parts
|
||||
|
||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
function composerCursor(editor: HTMLDivElement) {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||
|
||||
return range.toString().length
|
||||
}
|
||||
|
||||
@@ -578,7 +513,6 @@ export function ComposerAttachments(props: {
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
|
||||
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
@@ -636,7 +570,6 @@ export function ComposerAttachments(props: {
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Keep loading local; the route boundary would detach the screen and drop editor focus. */}
|
||||
@@ -770,7 +703,6 @@ function ComposerEditorConfiguredSelect(props: {
|
||||
}) {
|
||||
const current = () => props.control.current()
|
||||
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
|
||||
|
||||
return (
|
||||
<ComposerEditorSelect
|
||||
title={props.title}
|
||||
@@ -858,17 +790,13 @@ export function ComposerEditorPopover(props: {
|
||||
onSelect: (item: ComposerSuggestion) => void
|
||||
}) {
|
||||
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
|
||||
|
||||
const resize = (height: number) =>
|
||||
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
|
||||
|
||||
// A detached boundary (e.g. the previous session's timeline while the next one loads) measures 0px.
|
||||
const boundary = () => {
|
||||
const element = props.boundary?.()
|
||||
|
||||
return element?.isConnected ? element : undefined
|
||||
}
|
||||
|
||||
createEffect(() => resize(boundary()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
|
||||
createResizeObserver(boundary, (rect) => resize(rect.height))
|
||||
|
||||
@@ -934,9 +862,7 @@ function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
|
||||
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
|
||||
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
|
||||
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
|
||||
|
||||
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
|
||||
|
||||
return (
|
||||
chrome +
|
||||
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
|
||||
@@ -952,20 +878,14 @@ function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
|
||||
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
|
||||
const i18n = useI18n()
|
||||
const view = props.controller.view
|
||||
|
||||
const action = createMemo(() => {
|
||||
const queue = view.submit.queue
|
||||
|
||||
if (!queue || !props.controller.canSubmit()) return undefined
|
||||
|
||||
if (queue.editing()) return "steer" as const
|
||||
|
||||
return queue.alternate()
|
||||
})
|
||||
|
||||
const [button, setButton] = createSignal<HTMLButtonElement>()
|
||||
const presence = createAnimatedPresence(action, () => button() ?? null)
|
||||
|
||||
return (
|
||||
<Show when={presence.present() && presence.value()} keyed>
|
||||
{(delivery) => (
|
||||
@@ -1021,13 +941,10 @@ export function ComposerEditorSubmitButton(props: {
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (props.stopping) {
|
||||
props.onStop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
props.onSubmit()
|
||||
}}
|
||||
/>
|
||||
@@ -1037,11 +954,8 @@ export function ComposerEditorSubmitButton(props: {
|
||||
|
||||
function ComposerSuggestionIcon(props: { item: ComposerSuggestion }) {
|
||||
if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
|
||||
|
||||
if (props.item.kind === "skill") return <Icon name="post-skill" size="small" class="shrink-0" />
|
||||
|
||||
if (props.item.kind === "command") return null
|
||||
|
||||
return (
|
||||
<FileIcon
|
||||
node={{ path: props.item.path ?? props.item.label, type: props.item.kind === "reference" ? "directory" : "file" }}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ImagePreview } from "@opencode/ui/image-preview"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import type { ReferenceInfo } from "@opencode/client/promise"
|
||||
import type { Links, MountedSession } from "@opencode/gui-extensions/sdk"
|
||||
import type { Links, SessionView } from "@opencode/gui-extensions/sdk"
|
||||
import { createComponent, createEffect, createMemo, on } from "solid-js"
|
||||
import { Predicate } from "effect"
|
||||
import type { ComposerSuggestion } from "./types"
|
||||
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
|
||||
@@ -11,7 +10,7 @@ import { useComments } from "@/composer/comments"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/services"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
@@ -31,20 +30,12 @@ export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
}
|
||||
|
||||
const sendFailedTitle = {
|
||||
shell: "prompt.toast.shellSendFailed.title",
|
||||
command: "prompt.toast.commandSendFailed.title",
|
||||
prompt: "prompt.toast.promptSendFailed.title",
|
||||
} as const
|
||||
|
||||
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
|
||||
const available = () =>
|
||||
server.conn.type !== "extension" || !server.conn.managed || server.ctx.sdk.connection.status() === "connected"
|
||||
|
||||
const files = useFile()
|
||||
const links = useExtensionHost().links
|
||||
const extensions = useExtensionAttachment()
|
||||
@@ -69,59 +60,43 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
)
|
||||
const mode = () => interaction[0].mode
|
||||
const history = createComposerHistory()
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = extensions.files.opened()
|
||||
const active = extensions.files.active()
|
||||
|
||||
return active ? [active, ...all.filter((path) => path !== active)] : all
|
||||
})
|
||||
|
||||
const attachments = createMemo(() => prompt.current().filter(isAttachment))
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
})
|
||||
|
||||
const blank = createMemo(() => {
|
||||
const text = prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
|
||||
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
|
||||
const stopping = createMemo(() => adapter.working() && blank())
|
||||
|
||||
const placeholder = () => {
|
||||
if (mode() === "shell") return language.t("prompt.placeholder.shell", { example: "git status" })
|
||||
|
||||
if (adapter.working() || (options?.queue?.count() ?? 0) > 0)
|
||||
return language.t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
|
||||
|
||||
return language.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
}
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
const comment = item.comment?.trim()
|
||||
|
||||
if (!comment || item.type !== "file") return []
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
|
||||
const nextSelection =
|
||||
selection ??
|
||||
(item.selection
|
||||
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
||||
: undefined)
|
||||
|
||||
if (!nextSelection) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.commentID ?? item.key,
|
||||
@@ -135,7 +110,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
||||
comments.replace(
|
||||
items.map((item) => ({
|
||||
@@ -163,7 +137,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
|
||||
const references = createMemo(() =>
|
||||
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||
.filter((reference) => !reference.hidden)
|
||||
@@ -184,9 +157,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
|
||||
|
||||
const context = createMemo<ComposerSuggestion[]>(() => [
|
||||
...references(),
|
||||
...skills().map((skill) => ({
|
||||
@@ -221,7 +192,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
])
|
||||
|
||||
const slashCommands = createMemo(() => [
|
||||
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
@@ -241,7 +211,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
|
||||
const commands = createMemo<ComposerSuggestion[]>(() => [
|
||||
...slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
@@ -253,9 +222,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
])
|
||||
|
||||
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
|
||||
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
@@ -269,9 +236,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
delivery: (alternate) => {
|
||||
const queue = options?.queue
|
||||
|
||||
if (!queue) return "steer"
|
||||
|
||||
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
|
||||
},
|
||||
notify: {
|
||||
@@ -282,7 +247,13 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}),
|
||||
failed: (kind, error) =>
|
||||
showToast({
|
||||
title: language.t(sendFailedTitle[kind]),
|
||||
title: language.t(
|
||||
kind === "shell"
|
||||
? "prompt.toast.shellSendFailed.title"
|
||||
: kind === "command"
|
||||
? "prompt.toast.commandSendFailed.title"
|
||||
: "prompt.toast.promptSendFailed.title",
|
||||
),
|
||||
description:
|
||||
kind === "command"
|
||||
? formatServerError(error, language.t, language.t("common.requestFailed"))
|
||||
@@ -296,13 +267,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
clientCommand: (text) => {
|
||||
const selected = parseClientSlashCommand(slashCommands(), text)
|
||||
|
||||
if (!selected) return
|
||||
|
||||
return () => command.trigger(selected.id, "slash", selected.input)
|
||||
},
|
||||
})
|
||||
|
||||
const controller = createComposerEditor({
|
||||
store: prompt.store,
|
||||
state: interaction,
|
||||
@@ -310,7 +278,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
|
||||
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||
capture: historyComments,
|
||||
// SAFETY: `entries` stores each entry's comments as its metadata, and the editor restores only metadata it read there.
|
||||
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||
},
|
||||
commands,
|
||||
@@ -334,32 +301,23 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
|
||||
if (item?.type === "note") {
|
||||
// The extension that attached the note reveals its subject.
|
||||
const href = item.live?.href ?? item.href
|
||||
|
||||
if (href) links.open({ href, origin: item.origin, session: extensions.current() })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (item) openComment(item, links, extensions.current(), files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
// SAFETY: the editor's only `setEditor` call passes its editable root, a `<div>` (`editor/editor.tsx`).
|
||||
editor = element as HTMLDivElement
|
||||
|
||||
if (adapter.kind === "active-session") adapter.setEditor(editor)
|
||||
},
|
||||
onSuggestionSelect(item) {
|
||||
if (item.kind !== "command") return
|
||||
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||
|
||||
if (!selected || selected.type === "custom") return
|
||||
|
||||
if (selected.arguments) return
|
||||
|
||||
return () => command.trigger(selected.id, "slash")
|
||||
},
|
||||
attachments: {
|
||||
@@ -389,7 +347,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
placeholder,
|
||||
get agent() {
|
||||
const agents = adapter.controls().agents
|
||||
|
||||
return agents.visible && agents.options.length > 0
|
||||
? {
|
||||
options: () => adapter.controls().agents.options.map((name) => ({ id: name, label: name })),
|
||||
@@ -413,25 +370,20 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
|
||||
if (queue?.undoing()) return
|
||||
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
if (queue?.editing()) {
|
||||
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void submission.submit(new Event("submit"), submitOptions)
|
||||
},
|
||||
onStop: () => void submission.stop(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
|
||||
|
||||
command.register("composer-editor", () => [
|
||||
@@ -462,51 +414,42 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
])
|
||||
|
||||
// SAFETY: the `model` getter defined on the controller above completes `ComposerModel`.
|
||||
return controller as ComposerModel
|
||||
}
|
||||
|
||||
function composerErrorMessage(language: ReturnType<typeof useLanguage>, cause: unknown) {
|
||||
if (Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)) return cause.message
|
||||
|
||||
if (
|
||||
Predicate.hasProperty(cause, "data") &&
|
||||
Predicate.hasProperty(cause.data, "message") &&
|
||||
Predicate.isString(cause.data.message) &&
|
||||
cause.data.message
|
||||
)
|
||||
return cause.data.message
|
||||
|
||||
function composerErrorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
links: Links,
|
||||
session: MountedSession | undefined,
|
||||
session: SessionView | undefined,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
) {
|
||||
if (!item.commentID) return
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
|
||||
const queueFocus = (attempts = 6) => {
|
||||
requestAnimationFrame(() => {
|
||||
comments.setFocus({ ...focus })
|
||||
|
||||
if (attempts <= 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const current = comments.focus()
|
||||
|
||||
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// The extension that owns the comment's origin reveals it (the review diff or a file tab), keeping the conversation.
|
||||
links.open({ href: item.path, origin: item.commentOrigin, exact: true, background: true, session })
|
||||
|
||||
if (item.commentOrigin === "review") return queueFocus()
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { CustomProviderForm } from "@/providers/credentials/dialog"
|
||||
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext, useContext } from "solid-js"
|
||||
import type { createExtensionAttachment } from "./host-apis"
|
||||
|
||||
// Apart from `host-apis.tsx`, so session code such as the side panels reads the attachment without loading the HostApis.
|
||||
const AttachmentContext = createContext<ReturnType<typeof createExtensionAttachment>>()
|
||||
|
||||
export function useExtensionAttachment() {
|
||||
const value = useContext(AttachmentContext)
|
||||
|
||||
if (!value) throw new Error("Extension attachment is unavailable")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const ExtensionAttachmentProvider = AttachmentContext.Provider
|
||||
@@ -1,827 +0,0 @@
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
on,
|
||||
runWithOwner,
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { createStore, produce, type Store } from "solid-js/store"
|
||||
import type { Schema } from "effect"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import {
|
||||
Panel,
|
||||
type Build,
|
||||
type Layout,
|
||||
type MountedSession,
|
||||
type OpenOptions,
|
||||
type PanelSidebar,
|
||||
type PanelState,
|
||||
type ServerRef,
|
||||
type SessionRef,
|
||||
type SessionScreen,
|
||||
type Storage,
|
||||
type StorageScope,
|
||||
type StoreFrom,
|
||||
type StoreOptions,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { useGlobal, type ServerCtx } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { SessionRouteKey, SessionStateKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
|
||||
import { terminalFontFamily, useSettings } from "@/settings/model"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createEmbeds } from "./embeds"
|
||||
import { useExtensionHost, type HostApiFactories } from "./host"
|
||||
import { createLocatedWrites } from "./located"
|
||||
import type { Region } from "./panels"
|
||||
import { deferredHandle, globalStoreTarget, persistedHandle, storeImports, storeName } from "./stores"
|
||||
|
||||
type Attached = {
|
||||
sessions: Accessor<readonly SessionRef[]>
|
||||
current: Accessor<MountedSession | undefined>
|
||||
screen: Accessor<SessionScreen | undefined>
|
||||
scope: (server: string) => ServerScope
|
||||
/** Records a session-scoped store so layout pruning drops it with the session. */
|
||||
scoped: (name: string) => void
|
||||
layout: Omit<Layout, "narrow" | "settings" | "project" | "stored"> & {
|
||||
stored(extension: string, session: SessionRef): readonly string[]
|
||||
}
|
||||
settings: (page?: string) => void
|
||||
project: (server: string, title: string) => void
|
||||
font: Accessor<string>
|
||||
routing: Accessor<boolean>
|
||||
path: Accessor<string>
|
||||
keybind: (command: string) => readonly string[]
|
||||
matches: (command: string, event: KeyboardEvent) => boolean
|
||||
servers: Accessor<readonly string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* HostApis the host owns. Session and layout attach once the app interface mounts; until then their reads return
|
||||
* their documented defaults and their writes wait, then apply in call order.
|
||||
*/
|
||||
export function createHostApis() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
// The session screen's breakpoint, negated, so no fractional width is both narrow and desktop.
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
const [attached, setAttached] = createSignal<Attached>()
|
||||
const removed = new Set<(value: { server: string; directory: string }) => void>()
|
||||
const memory = new Map<string, ReturnType<Storage["memory"]>>()
|
||||
const current = () => attached()
|
||||
// Writes made while the app interface is not mounted, in call order.
|
||||
const early: ((value: Attached) => void)[] = []
|
||||
// Removals of a session's store wait for its location, as layout writes do.
|
||||
const located = createLocatedWrites()
|
||||
|
||||
// Runs a write now when the interface is mounted and nothing waits before it; otherwise once it mounts.
|
||||
const write = (run: (value: Attached) => void) => {
|
||||
const value = untrack(attached)
|
||||
|
||||
if (value && early.length === 0) return run(value)
|
||||
early.push(run)
|
||||
}
|
||||
|
||||
// Promise.try runs each write synchronously and isolates a throw, such as an unlisted server, from the later ones.
|
||||
const flush = () => {
|
||||
const value = untrack(attached)
|
||||
|
||||
if (!value) return
|
||||
batch(() =>
|
||||
early
|
||||
.splice(0)
|
||||
.forEach(
|
||||
(run) => void Promise.try(() => run(value)).catch((cause: unknown) => console.error("[extension]", cause)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const embeds = createEmbeds({
|
||||
bridge: platform.extensions,
|
||||
zoom: () => platform.webviewZoom?.() ?? 1,
|
||||
dialog: () => !!dialog.active,
|
||||
})
|
||||
|
||||
// A server or session store's key needs the server's scope, which the mounted interface provides.
|
||||
const target = (
|
||||
extension: string,
|
||||
key: string,
|
||||
scope: Exclude<StorageScope, "global">,
|
||||
from: StoreFrom | readonly StoreFrom[] | undefined,
|
||||
connected: Attached,
|
||||
) => {
|
||||
const name = storeName(extension, key)
|
||||
|
||||
if ("session" in scope) {
|
||||
const location = scope.session.location
|
||||
|
||||
if (!location) throw new Error("Session storage requires a session location")
|
||||
connected.scoped(name)
|
||||
const server = connected.scope(scope.session.server.id)
|
||||
const directory = base64Encode(location.directory)
|
||||
const session = SessionStateKey.from(server, SessionRouteKey.fromRoute(directory, scope.session.id))
|
||||
|
||||
return {
|
||||
...Persist.serverSession(server, directory, scope.session.id, name),
|
||||
copyFrom: storeImports(from, session),
|
||||
}
|
||||
}
|
||||
|
||||
const copyFrom = storeImports(from)
|
||||
|
||||
if (!scope.directory) return { ...Persist.serverGlobal(connected.scope(scope.server), name), copyFrom }
|
||||
|
||||
return { ...Persist.serverWorkspace(connected.scope(scope.server), base64Encode(scope.directory), name), copyFrom }
|
||||
}
|
||||
|
||||
const apis: HostApiFactories = {
|
||||
storage: (extension, owner) => ({
|
||||
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>) {
|
||||
const open = (at: Parameters<typeof persisted>[0]) => {
|
||||
const pair = persisted(at, options.schema, options.initial, platform)
|
||||
|
||||
return persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
|
||||
}
|
||||
|
||||
const scope = options.scope
|
||||
|
||||
// Persistence owns effects and resources; code after an await in setup has no owner.
|
||||
return runWithOwner(getOwner() ?? owner, () => {
|
||||
if (!scope || scope === "global") return open(globalStoreTarget(extension, key, options.from))
|
||||
|
||||
const scoped = (connected: Attached | undefined) =>
|
||||
connected && open(target(extension, key, scope, options.from, connected))
|
||||
|
||||
// A server store opens once the interface mounts. A session store also waits for the session's location, and
|
||||
// opens again in a new directory; the store a run opened disposes with the next run.
|
||||
if (!("session" in scope)) return deferredHandle(createMemo(on(attached, scoped)))
|
||||
|
||||
const directory = createMemo(() => scope.session.location?.directory)
|
||||
|
||||
return deferredHandle(
|
||||
createMemo(
|
||||
on([attached, directory], ([connected, value]) => (value === undefined ? undefined : scoped(connected))),
|
||||
),
|
||||
)
|
||||
})!
|
||||
},
|
||||
memory<T extends object>(key: string, options: { readonly initial: T }) {
|
||||
const name = `${extension}.${key}`
|
||||
const existing = memory.get(name)
|
||||
|
||||
if (existing) {
|
||||
// SAFETY: a memory key is one extension's store, which that extension always opens with the same shape.
|
||||
return existing as readonly [Store<T>, (mutation: (draft: T) => void) => void]
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore(options.initial)
|
||||
const value = [store, (mutation: (draft: T) => void) => setStore(produce(mutation))] as const
|
||||
|
||||
memory.set(name, value)
|
||||
|
||||
return value
|
||||
},
|
||||
remove(key, options) {
|
||||
const scope = options?.scope
|
||||
|
||||
if (!scope || scope === "global")
|
||||
return removePersisted(globalStoreTarget(extension, key, options?.from), platform)
|
||||
|
||||
const run = (connected: Attached) =>
|
||||
removePersisted(target(extension, key, scope, options?.from, connected), platform)
|
||||
|
||||
// A session's store is found through its location: the removal waits for it, as layout writes do.
|
||||
write((connected) =>
|
||||
"session" in scope && !scope.session.location
|
||||
? located.hold(scope.session, () => write(run))
|
||||
: run(connected),
|
||||
)
|
||||
},
|
||||
}),
|
||||
system: () => ({
|
||||
copy: (text) => platform.writeClipboardText?.(text) ?? navigator.clipboard.writeText(text),
|
||||
async save(file) {
|
||||
if (platform.saveFile) return platform.saveFile({ defaultPath: file.name }, file.content)
|
||||
const url = URL.createObjectURL(new Blob([file.content], { type: "application/octet-stream" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = file.name
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
return true
|
||||
},
|
||||
openExternal(url) {
|
||||
if (platform.openLocalFile && URL.canParse(url) && new URL(url).protocol === "file:")
|
||||
return platform.openLocalFile(url)
|
||||
platform.openExternal(url)
|
||||
},
|
||||
}),
|
||||
desktop: () =>
|
||||
platform.platform === "desktop"
|
||||
? {
|
||||
os: platform.os ?? "linux",
|
||||
window: platform.windowID,
|
||||
zoom: () => platform.webviewZoom?.() ?? 1,
|
||||
launch: (path, app) => platform.openPath?.(path, app) ?? Promise.resolve(),
|
||||
reveal: (path) => platform.revealPath?.(path) ?? Promise.resolve(false),
|
||||
installed: (app) => platform.checkAppExists?.(app) ?? Promise.resolve(false),
|
||||
forceFocus: (enabled) => platform.setForceFocus?.(enabled) ?? Promise.resolve(),
|
||||
}
|
||||
: undefined,
|
||||
build: () => ({
|
||||
version: platform.version ?? "",
|
||||
// SAFETY: the build sets VITE_OPENCODE_CHANNEL to one of the release channels, or leaves it unset locally.
|
||||
channel: (import.meta.env.VITE_OPENCODE_CHANNEL ?? "local") as Build["channel"],
|
||||
platform: platform.platform,
|
||||
packaged: platform.extensions?.packaged ?? false,
|
||||
}),
|
||||
locale: () => ({
|
||||
locale: language.intl,
|
||||
direction: language.direction,
|
||||
setDirection: language.setDirection,
|
||||
}),
|
||||
// Before the interface mounts, the default mono font.
|
||||
appearance: () => ({ font: () => current()?.font() ?? terminalFontFamily(undefined) }),
|
||||
router: () => ({
|
||||
routing: () => current()?.routing() ?? false,
|
||||
path: () => current()?.path() ?? "",
|
||||
}),
|
||||
keybinds: () => ({
|
||||
keybind: (command) => current()?.keybind(command) ?? [],
|
||||
keys: (bind) => formatKeybindParts(bind, language.t),
|
||||
matches: (command, event) => current()?.matches(command, event) ?? false,
|
||||
}),
|
||||
servers: () => ({ list: () => current()?.servers() ?? [] }),
|
||||
workspaces: (_extension, _owner, _context, register) => ({
|
||||
on(_event, handler) {
|
||||
removed.add(handler)
|
||||
|
||||
return register(() => {
|
||||
removed.delete(handler)
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessions: () => ({
|
||||
list: () => current()?.sessions() ?? [],
|
||||
current: () => current()?.current(),
|
||||
}),
|
||||
screen: () => ({ current: () => current()?.screen() }),
|
||||
// Before the interface mounts, reads return what an empty layout holds and writes wait for it.
|
||||
layout: (extension) => ({
|
||||
narrow,
|
||||
ready: () => current()?.layout.ready() ?? false,
|
||||
open: (key, session, options) => write((value) => value.layout.open(key, session, options)),
|
||||
close: (key, session) => write((value) => value.layout.close(key, session)),
|
||||
toggle: (key, session) => write((value) => value.layout.toggle(key, session)),
|
||||
state: (key, session) => current()?.layout.state(key, session) ?? "closed",
|
||||
stored: (session) => current()?.layout.stored(extension, session) ?? [],
|
||||
side: {
|
||||
opened: (session) => current()?.layout.side.opened(session) ?? false,
|
||||
toggle: (session) => write((value) => value.layout.side.toggle(session)),
|
||||
},
|
||||
sidebar: { opened: () => current()?.layout.sidebar.opened() ?? true },
|
||||
dock: {
|
||||
opened: (session) => current()?.layout.dock.opened(session) ?? false,
|
||||
placement: () => current()?.layout.dock.placement() ?? "side",
|
||||
},
|
||||
scroll: {
|
||||
get: (session, key) => current()?.layout.scroll.get(session, key),
|
||||
set: (session, key, value) => write((connected) => connected.layout.scroll.set(session, key, value)),
|
||||
},
|
||||
settings: (page) => write((value) => value.settings(page)),
|
||||
project: (server, title) => write((value) => value.project(server, title)),
|
||||
}),
|
||||
embeds: () => embeds,
|
||||
}
|
||||
|
||||
return {
|
||||
apis,
|
||||
/**
|
||||
* Runs `run` now while the app interface is mounted and no write waits; otherwise after the interface's first
|
||||
* render, behind the writes made before it. The returned function cancels a run that still waits.
|
||||
*/
|
||||
whenMounted(run: () => void) {
|
||||
const state = { cancelled: false }
|
||||
|
||||
write(() => {
|
||||
if (!state.cancelled) run()
|
||||
})
|
||||
|
||||
return () => {
|
||||
state.cancelled = true
|
||||
}
|
||||
},
|
||||
attach(value: Attached) {
|
||||
setAttached(() => value)
|
||||
// After the interface's first render, so the waiting writes find its layout, in call order.
|
||||
queueMicrotask(flush)
|
||||
|
||||
return () => {
|
||||
if (attached() === value) setAttached(undefined)
|
||||
}
|
||||
},
|
||||
workspaceRemoved(value: { server: string; directory: string }) {
|
||||
removed.forEach((handler) => handler(value))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HostApis = ReturnType<typeof createHostApis>
|
||||
|
||||
export { ExtensionAttachmentProvider, useExtensionAttachment } from "./attachment"
|
||||
|
||||
/** Builds the session and layout attachment before readiness; its `attach` connects HostApis as the routes render. */
|
||||
export function createExtensionAttachment(apis: HostApis) {
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const route = useCurrentRoute()
|
||||
const settings = useSettings()
|
||||
const surface = useSettingsSurface()
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
const location = useLocation()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
// The mounted session screen, from its first render, and its object for the session it routes once it has mounted;
|
||||
// one screen is mounted at a time.
|
||||
const [screen, setScreen] = createSignal<SessionScreen>()
|
||||
const [routedView, setRoutedView] = createSignal<Accessor<MountedSession>>()
|
||||
const refs = new Map<string, SessionRef>()
|
||||
|
||||
const connection = (id: string) => global.servers.list().find((item) => ServerConnection.key(item) === id)
|
||||
|
||||
// One ref per server id. A restarted server (e.g. an updated WSL server) gets a new controller under the same id,
|
||||
// so the ref follows the live controller instead of the one it was created with.
|
||||
const owner = getOwner()
|
||||
const serverRefs = new Map<string, ServerRef>()
|
||||
|
||||
const server = (id: string): ServerRef | undefined => {
|
||||
const conn = connection(id)
|
||||
|
||||
if (!conn) return
|
||||
const existing = serverRefs.get(id)
|
||||
|
||||
if (existing) return existing
|
||||
const key = ServerConnection.Key.make(id)
|
||||
|
||||
const live = runWithOwner(owner, () =>
|
||||
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
|
||||
)!
|
||||
|
||||
const ref: ServerRef = {
|
||||
id,
|
||||
get name() {
|
||||
return serverName(live().sdk.server) || id
|
||||
},
|
||||
get url() {
|
||||
return live().sdk.url
|
||||
},
|
||||
get password() {
|
||||
return live().sdk.server.http.password
|
||||
},
|
||||
get client() {
|
||||
return live().sdk.api
|
||||
},
|
||||
get data() {
|
||||
return live().data
|
||||
},
|
||||
get local() {
|
||||
return ServerConnection.local(live().sdk.server)
|
||||
},
|
||||
get builtin() {
|
||||
return ServerConnection.builtin(live().sdk.server)
|
||||
},
|
||||
get compatible() {
|
||||
return !global.servers.health[key]?.incompatible
|
||||
},
|
||||
get connected() {
|
||||
return live().sdk.connection.status() === "connected"
|
||||
},
|
||||
}
|
||||
|
||||
serverRefs.set(id, ref)
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
|
||||
Array.from(refs).forEach(([key, ref]) => {
|
||||
if (!owned.has(ref.tab)) refs.delete(key)
|
||||
})
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "session") return
|
||||
const target = server(tab.server)
|
||||
|
||||
if (!target) return
|
||||
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
|
||||
const key = `${tab.server}\n${id}`
|
||||
|
||||
if (refs.has(key)) return
|
||||
refs.set(key, {
|
||||
key,
|
||||
id,
|
||||
tab: tabKey(tab),
|
||||
server: target,
|
||||
get pending() {
|
||||
return target.data.session.creating(id)
|
||||
},
|
||||
get location() {
|
||||
return target.data.session.get(id)?.location
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(refs.values())
|
||||
})
|
||||
|
||||
const routed = createMemo(() => {
|
||||
const value = route()
|
||||
|
||||
return value.type === "session" ? `${value.server}\n${value.sessionId}` : undefined
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const key = routed()
|
||||
const view = routedView()?.()
|
||||
|
||||
return key && view?.key === key ? view : undefined
|
||||
})
|
||||
|
||||
const scope = (id: string) => {
|
||||
const conn = connection(id)
|
||||
|
||||
if (!conn) throw new Error(`Server ${id} is unavailable`)
|
||||
|
||||
return global.ensureServerCtx(conn).sdk.scope
|
||||
}
|
||||
|
||||
const stateKey = (session: SessionRef) => {
|
||||
const location = session.location
|
||||
|
||||
if (!location) return
|
||||
|
||||
return SessionStateKey.from(
|
||||
scope(session.server.id),
|
||||
SessionRouteKey.fromRoute(base64Encode(location.directory), session.id),
|
||||
)
|
||||
}
|
||||
|
||||
const shellTab = (session: SessionRef) =>
|
||||
findSessionTab(tabs.store, ServerConnection.Key.make(session.server.id), session.id)
|
||||
|
||||
const sideOpened = (session: SessionRef) => !!tabs.region(shellTab(session), "side")
|
||||
const dockOpened = (session: SessionRef) => !!tabs.region(shellTab(session), "dock")
|
||||
const setDock = (session: SessionRef, opened: boolean) => tabs.setRegion(shellTab(session), "dock", opened)
|
||||
|
||||
// A token per session whose side region is open, new each time the region opens.
|
||||
const sideVisits = createMemo<ReadonlyMap<string, object>>(
|
||||
(previous) =>
|
||||
new Map(
|
||||
sessions().flatMap((session) =>
|
||||
sideOpened(session) ? [[session.key, previous.get(session.key) ?? {}] as const] : [],
|
||||
),
|
||||
),
|
||||
new Map(),
|
||||
)
|
||||
|
||||
// The panel whose toggle opened a side region, by the region's token. However the region closes, it reopens with
|
||||
// a new token, so a region reopened any other way belongs to the user.
|
||||
const openedFor = new WeakMap<object, string>()
|
||||
|
||||
// Keys are `${extension}:${tab id}`; the extension's panel decides the region.
|
||||
const provider = (key: string) => {
|
||||
const extension = key.slice(0, key.indexOf(":"))
|
||||
const matches = host.items(Panel).filter((item) => item.extension === extension)
|
||||
|
||||
return matches.find((item) => item.value.region === "side") ?? matches[0]
|
||||
}
|
||||
|
||||
const mountedSession = (session: SessionRef) => {
|
||||
const view = current()
|
||||
|
||||
return view?.key === session.key ? view : undefined
|
||||
}
|
||||
|
||||
// Counts routing visits: each change of the routed session, including to none (e.g. Home), starts the next one.
|
||||
const visit = createMemo(on(routed, (_key, _previous, count: number = 0) => count + 1))
|
||||
|
||||
// The narrow-screen view belongs to the routed, mounted session for one visit, and reads as the conversation once
|
||||
// another visit starts. A view selected for a session that is not routed (e.g. a file link that opens Files on
|
||||
// another session) belongs to the next visit, which the navigation that follows starts. The dock's view follows
|
||||
// the dock's own per-session state instead.
|
||||
const [mobile, setMobile] = createStore<{ session: string | undefined; view: string; visit: number }>({
|
||||
session: undefined,
|
||||
view: "session",
|
||||
visit: 0,
|
||||
})
|
||||
|
||||
const mobileView = createMemo(() =>
|
||||
mobile.session === current()?.key && mobile.visit === visit() ? mobile.view : "session",
|
||||
)
|
||||
|
||||
const selectMobile = (session: SessionRef, view: string) =>
|
||||
setMobile({ session: session.key, view, visit: session.key === routed() ? visit() : visit() + 1 })
|
||||
|
||||
// The side tabs a mounted session lists right now, plus `adding` as if it were stored; unmounted sessions have none.
|
||||
const listed = (session: SessionRef, value: string, adding?: string) => {
|
||||
const view = mountedSession(session)
|
||||
|
||||
if (!view) return []
|
||||
const all = layout.panel.state(value).all
|
||||
const stored = adding && !all.includes(adding) ? [...all, adding] : all
|
||||
|
||||
return untrack(() =>
|
||||
host
|
||||
.items(Panel)
|
||||
.filter((item) => item.value.region === "side")
|
||||
.flatMap((item) => {
|
||||
const prefix = `${item.extension}:`
|
||||
const open = stored.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
|
||||
|
||||
return item.value.list(view, open).map((tab) => ({ key: `${prefix}${tab.id}`, tab }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Writes made before a session's location is known wait for it rather than being dropped.
|
||||
const located = createLocatedWrites()
|
||||
|
||||
const open = (key: string, session: SessionRef, options?: OpenOptions) => {
|
||||
const item = provider(key)
|
||||
|
||||
if (item?.value.region === "dock") return setDock(session, true)
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return located.hold(session, () => open(key, session, options))
|
||||
const placement = options?.tab ?? "open"
|
||||
|
||||
// An append adds the tab quietly: no selection, no region change, no preview replacement.
|
||||
if (placement === "append") return layout.panel.append(value, key)
|
||||
|
||||
// A select keeps the narrow-screen view and dock, as a background open does, and opens the side region too.
|
||||
if (placement === "select")
|
||||
return batch(() => {
|
||||
tabs.setRegion(shellTab(session), "side", true)
|
||||
layout.panel.append(value, key)
|
||||
layout.panel.focus(value, key)
|
||||
})
|
||||
// Lists the opened tab too, so its own fields apply before it is stored. The next preview replaces a transient tab.
|
||||
const known = listed(session, value, key)
|
||||
const transient = new Set(known.flatMap((entry) => (entry.tab.transient ? [entry.key] : [])))
|
||||
const first = known.some((entry) => entry.key === key && entry.tab.first)
|
||||
batch(() => {
|
||||
if (narrow() && !options?.background) {
|
||||
setDock(session, false)
|
||||
|
||||
if (item?.value.mobile) selectMobile(session, `${item.extension}:${item.value.id}`)
|
||||
|
||||
// A tab its panel does not list, or a transient one, stays unstored: the open only selects the panel's view.
|
||||
if (mountedSession(session) && !known.some((entry) => entry.key === key && !entry.tab.transient)) return
|
||||
}
|
||||
|
||||
// A background open keeps the narrow-screen view, but its tab still shows once the window is wide.
|
||||
if (!narrow() || options?.background) tabs.setRegion(shellTab(session), "side", true)
|
||||
|
||||
// Pinned tabs are listed without being stored; opening one only selects it.
|
||||
if (known.some((entry) => entry.key === key && entry.tab.pinned)) return layout.panel.focus(value, key)
|
||||
|
||||
if (placement === "preview") return layout.panel.preview(value, key, transient)
|
||||
layout.panel.open(value, key, transient, first)
|
||||
})
|
||||
}
|
||||
|
||||
const close = (key: string, session: SessionRef) => {
|
||||
const item = provider(key)
|
||||
|
||||
if (item?.value.region === "dock") return setDock(session, false)
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return located.hold(session, () => close(key, session))
|
||||
const tab = listed(session, value).find((entry) => entry.key === key)?.tab
|
||||
layout.panel.close(value, key)
|
||||
const view = mountedSession(session)
|
||||
|
||||
if (view && tab) item?.value.close?.(tab, view)
|
||||
}
|
||||
|
||||
// The routed session's side region, which knows the fallback selection the stored state lacks.
|
||||
const [region, setRegion] = createSignal<Region>()
|
||||
// The session screen's inner sidebar preference, which its side panels share.
|
||||
const [sidebar, setSidebar] = createSignal<PanelSidebar>()
|
||||
|
||||
const opened = createMemo(
|
||||
() => Array.from(new Set((region()?.entries() ?? []).flatMap((entry) => entry.tab.file ?? []))),
|
||||
[],
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const state = (key: string, session: SessionRef): PanelState => {
|
||||
if (provider(key)?.value.region === "dock") return dockOpened(session) ? "visible" : "closed"
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return "closed"
|
||||
const panel = layout.panel.state(value)
|
||||
const active = mountedSession(session) ? (region()?.active() ?? panel.active) : panel.active
|
||||
|
||||
if (active !== key) return panel.all.includes(key) ? "open" : "closed"
|
||||
|
||||
return sideOpened(session) ? "visible" : "active"
|
||||
}
|
||||
|
||||
// Open-project requests wait until their server is listed (e.g. an SSH server that just connected).
|
||||
const servers = useServers()
|
||||
const picker = useDirectoryPicker()
|
||||
const [projects, setProjects] = createSignal<readonly { server: string; title: string }[]>([])
|
||||
createEffect(() => {
|
||||
const pending = projects()
|
||||
|
||||
const ready = pending.flatMap((request) => {
|
||||
const server = servers.list.find((conn) => ServerConnection.key(conn) === request.server)
|
||||
|
||||
return server ? [{ request, server }] : []
|
||||
})
|
||||
|
||||
if (ready.length === 0) return
|
||||
setProjects(pending.filter((request) => !ready.some((item) => item.request === request)))
|
||||
untrack(() =>
|
||||
ready.forEach(({ request, server }) =>
|
||||
picker({
|
||||
server,
|
||||
title: request.title,
|
||||
onSelect: (value) => {
|
||||
const directory = Array.isArray(value) ? value[0] : value
|
||||
|
||||
if (!directory) return
|
||||
const key = ServerConnection.key(server)
|
||||
servers.projects.forServer(key).open(directory)
|
||||
void tabs.newDraft({ server: key, directory })
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const toggle = (key: string, session: SessionRef) => {
|
||||
if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session))
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return located.hold(session, () => toggle(key, session))
|
||||
const region = sideVisits().get(session.key)
|
||||
|
||||
if (state(key, session) === "visible") {
|
||||
batch(() => {
|
||||
close(key, session)
|
||||
|
||||
// Closing the last panel the region was opened for also closes the region.
|
||||
if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0)
|
||||
tabs.setRegion(shellTab(session), "side", false)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A panel opened into an open region makes the region the user's.
|
||||
if (region) openedFor.delete(region)
|
||||
open(key, session)
|
||||
const opened = sideVisits().get(session.key)
|
||||
|
||||
if (!region && opened) openedFor.set(opened, key)
|
||||
}
|
||||
|
||||
const setScroll = (session: SessionRef, key: string, next: { readonly x: number; readonly y: number }) => {
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return located.hold(session, () => setScroll(session, key, next))
|
||||
layout.panel.setScroll(value, key, next)
|
||||
}
|
||||
|
||||
const mounted: Attached = {
|
||||
sessions,
|
||||
current,
|
||||
screen,
|
||||
scope,
|
||||
scoped: layout.sessionState.track,
|
||||
project: (server, title) => setProjects((pending) => [...pending, { server, title }]),
|
||||
font: () => terminalFontFamily(settings.appearance.terminalFont()),
|
||||
routing: useIsRouting(),
|
||||
path: () => `${location.pathname}${location.search}`,
|
||||
keybind: command.keybindParts,
|
||||
matches: command.matches,
|
||||
servers: () => global.servers.list().map(ServerConnection.key),
|
||||
// SAFETY: an extension names a page it contributed through `SettingsPage`, which settings lists as an extension tab.
|
||||
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
|
||||
layout: {
|
||||
ready: layout.ready,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
state,
|
||||
stored(extension, session) {
|
||||
const value = stateKey(session)
|
||||
|
||||
if (!value) return []
|
||||
const prefix = `${extension}:`
|
||||
|
||||
return layout.panel
|
||||
.state(value)
|
||||
.all.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
|
||||
},
|
||||
side: {
|
||||
opened: sideOpened,
|
||||
toggle: (session) => tabs.setRegion(shellTab(session), "side", !sideOpened(session)),
|
||||
},
|
||||
// Open is the stored preference's default, which holds until a session screen shows the preference.
|
||||
sidebar: { opened: () => sidebar()?.opened() ?? true },
|
||||
dock: {
|
||||
opened: dockOpened,
|
||||
placement: settings.general.terminalPlacement,
|
||||
},
|
||||
scroll: {
|
||||
get(session, key) {
|
||||
const value = stateKey(session)
|
||||
|
||||
return value ? layout.panel.scroll(value, key) : undefined
|
||||
},
|
||||
set: setScroll,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Mounts the interface for the HostApis; returns its detach. Called as the routes first render, which waits for
|
||||
* every extension to settle, so writes and dialogs made before then apply after that render.
|
||||
*/
|
||||
attach: () => apis.attach(mounted),
|
||||
/** The routed `MountedSession`. */
|
||||
current,
|
||||
region(value: Region) {
|
||||
setRegion(() => value)
|
||||
|
||||
return () => {
|
||||
if (region() === value) setRegion(undefined)
|
||||
}
|
||||
},
|
||||
/** The session screen's inner sidebar preference, which `Layout.sidebar` reads. */
|
||||
sidebar(value: PanelSidebar) {
|
||||
setSidebar(() => value)
|
||||
|
||||
return () => {
|
||||
if (sidebar() === value) setSidebar(undefined)
|
||||
}
|
||||
},
|
||||
/** Workspace files the routed session's side tabs show, in strip order, and the selected one. */
|
||||
files: {
|
||||
opened,
|
||||
active: () => region()?.selected()?.tab.file,
|
||||
},
|
||||
mobile: {
|
||||
current: mobileView,
|
||||
select(view: string) {
|
||||
const session = current()
|
||||
|
||||
if (session) selectMobile(session, view)
|
||||
},
|
||||
},
|
||||
/** A session screen started rendering: `Screen.current` returns it until it unmounts. */
|
||||
screen(value: SessionScreen) {
|
||||
setScreen(() => value)
|
||||
|
||||
return () => {
|
||||
if (screen() === value) setScreen(undefined)
|
||||
}
|
||||
},
|
||||
/** A session screen mounted: `Sessions.current` reads its object for the routed session. */
|
||||
mount(view: Accessor<MountedSession>) {
|
||||
setRoutedView(() => view)
|
||||
|
||||
return () => {
|
||||
if (routedView() === view) setRoutedView(undefined)
|
||||
}
|
||||
},
|
||||
/** Starts loading every extension's declared session stores for a session the screen routes. */
|
||||
preload(view: MountedSession) {
|
||||
untrack(() => host.preload(view))
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,179 +0,0 @@
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import type { Ipc, IpcClient, IpcSpec } from "@opencode/gui-extensions/sdk"
|
||||
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
type Codec = NonNullable<IpcSpec["state"]>
|
||||
|
||||
/** A value an Ipc's codec decodes to; each Ipc's `IpcClient` gives it that Ipc's own type. */
|
||||
type Decoded = Codec["Type"]
|
||||
|
||||
/** A value as it crosses the bridge, encoded by an Ipc's codec. */
|
||||
type Encoded = Codec["Encoded"]
|
||||
|
||||
type IpcState = {
|
||||
available: Record<string, boolean | undefined>
|
||||
values: Record<string, Decoded>
|
||||
// Counts each time an Ipc becomes available, so users can tell a returning provider from the one they had.
|
||||
generations: Record<string, number | undefined>
|
||||
}
|
||||
|
||||
/** Renderer-side clients for the Ipcs that extensions provide in the main process. */
|
||||
export function createIpcClients(bridge: Bridge | undefined) {
|
||||
const [state, setState] = createStore<IpcState>({ available: {}, values: {}, generations: {} })
|
||||
|
||||
const setAvailable = (id: string, available: boolean) => {
|
||||
if (available && !state.available[id]) setState("generations", id, (value = 0) => value + 1)
|
||||
|
||||
setState("available", id, available)
|
||||
}
|
||||
|
||||
const specs = new Map<string, IpcSpec>()
|
||||
const clients = new Map<string, IpcClient<IpcSpec>>()
|
||||
const listeners = new Map<string, Set<(name: string, data: Decoded) => void>>()
|
||||
const subscribed = new Set<string>()
|
||||
// How many availability and state events reached each Ipc. Events are newer than any snapshot, so a
|
||||
// subscribe reply only fills in what no event changed while it was in flight.
|
||||
const changes = new Map<string, { available: number; state: number }>()
|
||||
|
||||
const changesOf = (id: string) => {
|
||||
const existing = changes.get(id)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const created = { available: 0, state: 0 }
|
||||
|
||||
changes.set(id, created)
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
const decodeState = (id: string, value: Encoded): Decoded => {
|
||||
const schema = specs.get(id)?.state
|
||||
|
||||
return schema ? Schema.decodeUnknownSync(schema)(value) : value
|
||||
}
|
||||
|
||||
const stop = bridge?.on((message) => {
|
||||
if (message.type === "state") {
|
||||
if (!specs.has(message.ipc)) return
|
||||
|
||||
changesOf(message.ipc).state++
|
||||
setState("values", message.ipc, reconcile(decodeState(message.ipc, message.state)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "available") {
|
||||
const changed = changesOf(message.ipc)
|
||||
|
||||
changed.available++
|
||||
|
||||
// Going away clears the state too.
|
||||
if (!message.available) changed.state++
|
||||
|
||||
batch(() => {
|
||||
setAvailable(message.ipc, message.available)
|
||||
|
||||
if (!message.available) setState("values", message.ipc, undefined)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type !== "event") return
|
||||
|
||||
const schema = specs.get(message.ipc)?.events?.[message.name]
|
||||
const data = schema ? Schema.decodeUnknownSync(schema)(message.data) : message.data
|
||||
|
||||
listeners.get(message.ipc)?.forEach((listener) => listener(message.name, data))
|
||||
})
|
||||
|
||||
const subscribe = (connected: Bridge, token: Ipc) => {
|
||||
if (subscribed.has(token.id)) return
|
||||
|
||||
subscribed.add(token.id)
|
||||
specs.set(token.id, token.spec)
|
||||
|
||||
const before = { ...changesOf(token.id) }
|
||||
|
||||
void connected.subscribe(token.id).then((result) => {
|
||||
const after = changesOf(token.id)
|
||||
|
||||
batch(() => {
|
||||
if (after.available === before.available) setAvailable(token.id, result.available)
|
||||
|
||||
if (after.state === before.state && result.state !== undefined)
|
||||
setState("values", token.id, decodeState(token.id, result.state))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const create = (connected: Bridge, token: Ipc): IpcClient<IpcSpec> => {
|
||||
const methods = Object.fromEntries(
|
||||
Object.entries(token.spec.methods).map(([name, method]) => [
|
||||
name,
|
||||
async (input: Decoded, options?: { signal?: AbortSignal }) => {
|
||||
const encoded = method.input ? Schema.encodeUnknownSync(method.input)(input) : null
|
||||
const output = await connected.call({ ipc: token.id, method: name, input: encoded }, options?.signal)
|
||||
|
||||
return method.output ? Schema.decodeUnknownSync(method.output)(output) : undefined
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const client = Object.assign(methods, {
|
||||
state: () => state.values[token.id],
|
||||
on(name: string, listener: (data: Decoded) => void) {
|
||||
const set = listeners.get(token.id) ?? new Set()
|
||||
|
||||
const wrapped = (event: string, data: Decoded) => {
|
||||
if (event === name) listener(data)
|
||||
}
|
||||
|
||||
set.add(wrapped)
|
||||
listeners.set(token.id, set)
|
||||
|
||||
return () => {
|
||||
set.delete(wrapped)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// SAFETY: `methods` holds one codec-checked function per method of the token's spec, beside `state` and `on`.
|
||||
return client as IpcClient<IpcSpec>
|
||||
}
|
||||
|
||||
const client = (token: Ipc) => {
|
||||
if (!bridge) return undefined
|
||||
|
||||
subscribe(bridge, token)
|
||||
|
||||
if (!state.available[token.id]) return undefined
|
||||
|
||||
const existing = clients.get(token.id)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const created = create(bridge, token)
|
||||
|
||||
clients.set(token.id, created)
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
/** How many times the Ipc became available; 0 until it first is. Reactive. */
|
||||
generation: (token: Ipc) => state.generations[token.id] ?? 0,
|
||||
/** A client typed by its token, for host code that uses one Ipc directly. */
|
||||
typed: <S extends IpcSpec>(token: Ipc<S>) =>
|
||||
// SAFETY: the client was built from this token's spec, so its methods, state and events are those of `S`.
|
||||
client(token) as IpcClient<S> | undefined,
|
||||
/** Stops listening to the bridge. Call it when the owner of these clients goes away. */
|
||||
dispose() {
|
||||
stop?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { batch, createEffect, createSignal, untrack } from "solid-js"
|
||||
import type { SessionRef } from "@opencode/gui-extensions/sdk"
|
||||
|
||||
/**
|
||||
* Layout writes find a session's stored layout through its location, which is unknown until the server reports it
|
||||
* (for example after a server re-authenticates). `hold` keeps such a write and runs it, in order, once the location is
|
||||
* known, so no write is lost.
|
||||
*/
|
||||
export function createLocatedWrites() {
|
||||
const [pending, setPending] = createSignal<readonly { readonly session: SessionRef; readonly run: () => void }[]>([])
|
||||
// Syncs held writes with the session locations the server reports.
|
||||
createEffect(() => {
|
||||
const held = pending()
|
||||
|
||||
if (held.length === 0) return
|
||||
const located = held.filter((item) => item.session.location)
|
||||
|
||||
if (located.length === 0) return
|
||||
setPending(held.filter((item) => !located.includes(item)))
|
||||
untrack(() => batch(() => located.forEach((item) => item.run())))
|
||||
})
|
||||
|
||||
return {
|
||||
hold(session: SessionRef, run: () => void) {
|
||||
setPending((held) => [...held, { session, run }])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createContext, onCleanup, useContext } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import type { Bridge, BridgeMenubarItem } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
/** MenubarItem contributions from main extensions, for the in-app menu on Windows and Linux. */
|
||||
export function createMenubarItems(bridge: Bridge | undefined) {
|
||||
const [state, setState] = createStore<{ items: BridgeMenubarItem[] }>({ items: [] })
|
||||
|
||||
if (bridge)
|
||||
onCleanup(
|
||||
bridge.on((message) => {
|
||||
if (message.type === "menubarItems") setState("items", reconcile([...message.items], { key: "id" }))
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
items: () => state.items,
|
||||
run: (id: string) => bridge?.runMenubarItem(id),
|
||||
}
|
||||
}
|
||||
|
||||
const MenubarItemsContext = createContext<ReturnType<typeof createMenubarItems>>()
|
||||
|
||||
export const MenubarItemsProvider = MenubarItemsContext.Provider
|
||||
|
||||
export function useMenubarItems() {
|
||||
return useContext(MenubarItemsContext)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, onCleanup, useContext } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import type { Bridge, BridgeMenubarItem } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
/** Native menubar items from main extensions, for the in-app menu on Windows and Linux. */
|
||||
export function createMenubar(bridge: Bridge | undefined) {
|
||||
const [state, setState] = createStore({ items: [] as BridgeMenubarItem[] })
|
||||
if (bridge)
|
||||
onCleanup(
|
||||
bridge.on((message) => {
|
||||
if (message.type === "menubar") setState("items", reconcile([...message.items], { key: "id" }))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
items: () => state.items,
|
||||
run: (id: string) => bridge?.menubar(id),
|
||||
}
|
||||
}
|
||||
|
||||
const MenubarContext = createContext<ReturnType<typeof createMenubar>>()
|
||||
|
||||
export const ExtensionMenubarProvider = MenubarContext.Provider
|
||||
|
||||
export function useExtensionMenubar() {
|
||||
return useContext(MenubarContext)
|
||||
}
|
||||
@@ -4,21 +4,13 @@ import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import {
|
||||
DrawerContext,
|
||||
Panel,
|
||||
type PanelSidebar,
|
||||
type PanelTab,
|
||||
type MountedSession,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { DrawerContext, Panel, type PanelSidebar, type PanelTab, type SessionView } from "@opencode/gui-extensions/sdk"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useExtensionHost } from "./host"
|
||||
import { panelKey } from "./panel-keys"
|
||||
import { MobilePanel, type Region, type RegionEntry } from "./panels"
|
||||
import { MobilePanel, panelKey, type Region, type RegionEntry } from "./panels"
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
@@ -27,25 +19,20 @@ export type MobileEntry = RegionEntry & { readonly mobile: NonNullable<Panel["mo
|
||||
/** Panels that offer a narrow-screen view, keyed `${extension}:${panel id}`. */
|
||||
export function createMobileViews() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
host.items(Panel).flatMap((item): MobileEntry[] => {
|
||||
const mobile = item.value.mobile
|
||||
|
||||
if (!mobile) return []
|
||||
const tab: PanelTab = { id: item.value.id, title: mobile.title }
|
||||
|
||||
return [
|
||||
{ key: panelKey(item.extension, item.value.id), extension: item.extension, tab, provider: item.value, mobile },
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
const sorted = (kinds: readonly string[]) =>
|
||||
entries()
|
||||
.filter((entry) => kinds.includes(entry.mobile.kind))
|
||||
.toSorted((a, b) => a.mobile.order - b.mobile.order)
|
||||
|
||||
return {
|
||||
entries,
|
||||
tabs: createMemo(() => sorted(["tab"])),
|
||||
@@ -61,30 +48,21 @@ export function MobileViewTabs(props: {
|
||||
views: MobileViews
|
||||
region: Region
|
||||
current: string
|
||||
session: MountedSession
|
||||
session: SessionView
|
||||
sidebar: PanelSidebar
|
||||
onSelect: (key: string) => void
|
||||
}): JSX.Element {
|
||||
const language = useLanguage()
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
menu: boolean
|
||||
drawer: string | undefined
|
||||
last: string | undefined
|
||||
loaded: boolean
|
||||
pending: string | undefined
|
||||
}>({
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
drawer: undefined,
|
||||
drawer: undefined as string | undefined,
|
||||
// Keeps the last drawer's content mounted while it animates closed.
|
||||
last: undefined,
|
||||
last: undefined as string | undefined,
|
||||
loaded: false,
|
||||
pending: undefined,
|
||||
pending: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const drawer = createMemo(() => (store.last ? props.views.find(store.last) : undefined))
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createRoot,
|
||||
createSignal,
|
||||
getOwner,
|
||||
on,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import type {
|
||||
BackgroundTask,
|
||||
Comments,
|
||||
Composer,
|
||||
Files,
|
||||
LineRange,
|
||||
ServerRef,
|
||||
MountedSession,
|
||||
SessionScreen,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { sessionInWorkspace, sessionProject, type SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useExtensionAttachment } from "./host-apis"
|
||||
|
||||
const noTasks: readonly BackgroundTask[] = []
|
||||
|
||||
/** The root of the routed session's object, which ends when the screen routes another session. */
|
||||
type ObjectRoot = { dispose?: () => void }
|
||||
|
||||
/**
|
||||
* The routed session as extensions see it: one frozen object per routed session, a new one each time the screen
|
||||
* routes another session, and the `SessionScreen` that owns the route-following files, comments and composer.
|
||||
*/
|
||||
export function createMountedSession(session: SessionModel) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
const composer = useComposerState()
|
||||
const server = useServer()
|
||||
const location = useWorkspaceLocation()
|
||||
const attachment = useExtensionAttachment()
|
||||
// The composer region owns background tasks and is created after the view.
|
||||
const [background, setBackground] = createSignal<Accessor<readonly BackgroundTask[]>>()
|
||||
|
||||
const serverRef: ServerRef = {
|
||||
get id() {
|
||||
return server.key
|
||||
},
|
||||
get name() {
|
||||
return serverName(server.conn) || server.key
|
||||
},
|
||||
get url() {
|
||||
return server.ctx.sdk.url
|
||||
},
|
||||
get password() {
|
||||
return server.conn.http.password
|
||||
},
|
||||
get client() {
|
||||
return server.ctx.sdk.api
|
||||
},
|
||||
get data() {
|
||||
return server.ctx.data
|
||||
},
|
||||
get local() {
|
||||
return server.isLocal
|
||||
},
|
||||
get builtin() {
|
||||
return ServerConnection.builtin(server.conn)
|
||||
},
|
||||
get compatible() {
|
||||
return !server.health?.incompatible
|
||||
},
|
||||
get connected() {
|
||||
return server.ctx.sdk.connection.status() === "connected"
|
||||
},
|
||||
}
|
||||
|
||||
const files: Files = {
|
||||
get root() {
|
||||
return location().directory
|
||||
},
|
||||
ready: file.ready,
|
||||
resolve: file.normalize,
|
||||
absolute: file.absolute,
|
||||
get: file.get,
|
||||
missing: file.notFound,
|
||||
sync: (path, options) => file.load(path, options),
|
||||
search: (query, options) =>
|
||||
options?.kind === "any" ? file.searchFilesAndDirectories(query, options) : file.searchFiles(query, options),
|
||||
selection: {
|
||||
// SAFETY: the file model returns the view cache's selection for the path, which its schema types as a line range.
|
||||
get: (path) => file.selectedLines(path) as LineRange | null | undefined,
|
||||
set: (path, range) => void file.setSelectedLines(path, range),
|
||||
},
|
||||
scroll: {
|
||||
get: (path) => ({
|
||||
// SAFETY: the file model returns the view cache's offsets for the path, which its schema types as numbers.
|
||||
top: file.scrollTop(path) as number | undefined,
|
||||
// SAFETY: as above.
|
||||
left: file.scrollLeft(path) as number | undefined,
|
||||
}),
|
||||
set(path, value) {
|
||||
if (value.top !== undefined) file.setScrollTop(path, value.top)
|
||||
|
||||
if (value.left !== undefined) file.setScrollLeft(path, value.left)
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
list: file.tree.children,
|
||||
state: file.tree.state,
|
||||
sync: (path, options) => (options?.force ? file.tree.refresh(path) : file.tree.list(path)),
|
||||
expand: (path, options) => void file.tree.expand(path, options),
|
||||
collapse: (path) => void file.tree.collapse(path),
|
||||
},
|
||||
}
|
||||
|
||||
const commentFile = (id: string) => comments.all().find((item) => item.id === id)?.file
|
||||
|
||||
const comment: Comments = {
|
||||
list: (path) => (path ? comments.list(path) : comments.all()),
|
||||
add: comments.add,
|
||||
update(id, text) {
|
||||
const path = commentFile(id)
|
||||
|
||||
if (path) comments.update(path, id, text)
|
||||
},
|
||||
remove(id) {
|
||||
const path = commentFile(id)
|
||||
|
||||
if (path) comments.remove(path, id)
|
||||
},
|
||||
focus: { current: comments.focus, set: (value) => void comments.setFocus(value) },
|
||||
active: { current: comments.active, set: (value) => void comments.setActive(value) },
|
||||
}
|
||||
|
||||
const composerRef: Composer = {
|
||||
attach: (part) => composer.context.add(part),
|
||||
update: (id, patch) => composer.context.updateComment(id, patch),
|
||||
detach: (id) => composer.context.removeComment(id),
|
||||
}
|
||||
|
||||
// Each object's memos live in its own root, which ends when the next object replaces it: a kept object stops
|
||||
// updating `project`, `listedProject` and `local`, and its other fields keep reading this session's data.
|
||||
const create = (input: { id: string; directory: string; tab: string; visit: object }): MountedSession => {
|
||||
const id = input.id
|
||||
const directory = input.directory
|
||||
const info = () => server.ctx.data.session.get(id)
|
||||
|
||||
// Global sync adds the worktrees found on disk, and the user's local name and icon override the server's.
|
||||
// Raw metadata stands in until global sync lists the project.
|
||||
const project = createMemo(() => {
|
||||
const value = info()
|
||||
|
||||
return (
|
||||
(value && server.ctx.projects.detailsForSession(value)) || sessionProject(server.ctx.data, value, directory)
|
||||
)
|
||||
})
|
||||
|
||||
// Only a project opened at this exact directory; a session in a project subfolder has none.
|
||||
const listedProject = createMemo(() => {
|
||||
const key = pathKey(directory)
|
||||
|
||||
return server.ctx.projects
|
||||
.list()
|
||||
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
|
||||
})
|
||||
|
||||
const local = createMemo(() => !sessionInWorkspace(server.ctx.sync.data.project, info(), directory))
|
||||
|
||||
const view: MountedSession = Object.freeze({
|
||||
key: `${server.key}\n${id}`,
|
||||
id,
|
||||
tab: input.tab,
|
||||
visit: input.visit,
|
||||
server: serverRef,
|
||||
get pending() {
|
||||
return server.ctx.data.session.creating(id)
|
||||
},
|
||||
get location() {
|
||||
return info()?.location
|
||||
},
|
||||
get project() {
|
||||
return project()
|
||||
},
|
||||
get listedProject() {
|
||||
return listedProject()
|
||||
},
|
||||
directory,
|
||||
get local() {
|
||||
return local()
|
||||
},
|
||||
// The composer region serves the routed session only.
|
||||
get background() {
|
||||
return mounted() === view ? (background()?.() ?? noTasks) : noTasks
|
||||
},
|
||||
})
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
// One object while this screen is mounted, whichever session it routes: its models follow the route.
|
||||
const screen: SessionScreen = Object.freeze({
|
||||
get session() {
|
||||
return attachment.current()
|
||||
},
|
||||
file: files,
|
||||
comment,
|
||||
composer: composerRef,
|
||||
})
|
||||
|
||||
// Before the screen's regions render, so every contribution they show reads it.
|
||||
onCleanup(attachment.screen(screen))
|
||||
|
||||
const owner = getOwner()
|
||||
const root: ObjectRoot = {}
|
||||
|
||||
onCleanup(() => root.dispose?.())
|
||||
|
||||
// A new object for each routed session, and for the same session once it moves to another directory or its shell tab
|
||||
// is known: its fields never change. A moment without an id keeps the last one. A move is the same routing visit.
|
||||
const mounted = createMemo<MountedSession>((previous) => {
|
||||
const id = session.identity.sessionID() ?? ""
|
||||
const directory = session.workspace.directory()
|
||||
const tab = session.layout.tabKey() ?? ""
|
||||
|
||||
if (previous && !id) return previous
|
||||
|
||||
if (previous && previous.id === id && previous.directory === directory && previous.tab === tab) return previous
|
||||
|
||||
const visit = previous?.id === id ? previous.visit : {}
|
||||
|
||||
return untrack(() =>
|
||||
createRoot((dispose) => {
|
||||
root.dispose?.()
|
||||
root.dispose = dispose
|
||||
|
||||
return create({ id, directory, tab, visit })
|
||||
}, owner),
|
||||
)
|
||||
})
|
||||
|
||||
// The session's declared stores start loading now, before its regions read them.
|
||||
createRenderEffect(on(mounted, (view) => attachment.preload(view)))
|
||||
// Registered once: `Sessions.current` follows the screen's object from here on.
|
||||
createEffect(() => onCleanup(attachment.mount(mounted)))
|
||||
|
||||
return { view: mounted, bindBackground: (tasks: Accessor<readonly BackgroundTask[]>) => setBackground(() => tasks) }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Panel } from "@opencode/gui-extensions/sdk"
|
||||
|
||||
/** The key the host stores a panel tab under. */
|
||||
export function panelKey(extension: string, id: string) {
|
||||
return `${extension}:${id}`
|
||||
}
|
||||
|
||||
/** The stored keys the panels map with `Panel.legacy`, each to the key of the tab it is now. */
|
||||
export function legacyKeys(providers: readonly { readonly extension: string; readonly value: Panel }[]) {
|
||||
return new Map(
|
||||
providers.flatMap((item) =>
|
||||
Object.entries(item.value.legacy ?? {}).map(([key, id]) => [key, panelKey(item.extension, id)] as const),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import type { MountedSession, PanelTab } from "@opencode/gui-extensions/sdk"
|
||||
import type { PanelTab } from "@opencode/gui-extensions/sdk"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { Contribution } from "./render"
|
||||
@@ -16,7 +16,6 @@ export function PanelTrigger(props: {
|
||||
value: string
|
||||
extension: string
|
||||
tab: PanelTab
|
||||
session: MountedSession
|
||||
index: number
|
||||
active: boolean
|
||||
preview: boolean
|
||||
@@ -26,9 +25,7 @@ export function PanelTrigger(props: {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
|
||||
// The label renders once per label function; state is read through getters so neither selection nor a session
|
||||
// switch remounts it.
|
||||
// The label renders once per label function; state is read through getters so selection never remounts it.
|
||||
const state = {
|
||||
get active() {
|
||||
return props.active
|
||||
@@ -36,23 +33,14 @@ export function PanelTrigger(props: {
|
||||
get preview() {
|
||||
return props.preview
|
||||
},
|
||||
get session() {
|
||||
return props.session
|
||||
},
|
||||
}
|
||||
|
||||
const label = createMemo(() => props.tab.label)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const render = label()
|
||||
|
||||
if (!render) return
|
||||
|
||||
return <Contribution extension={props.extension}>{() => untrack(() => render(state))}</Contribution>
|
||||
})
|
||||
|
||||
const content = () => rendered() ?? props.tab.title
|
||||
|
||||
const tooltip = (button: JSX.Element) => (
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -69,7 +57,6 @@ export function PanelTrigger(props: {
|
||||
{button}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const closeButton = (reveal: boolean) =>
|
||||
tooltip(
|
||||
<IconButton
|
||||
@@ -90,24 +77,9 @@ export function PanelTrigger(props: {
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>,
|
||||
)
|
||||
|
||||
const compactClose = () =>
|
||||
tooltip(<Tabs.CloseButton onClick={() => props.onClose(props.value)} aria-label={language.t("common.closeTab")} />)
|
||||
|
||||
// The close button `closable` asks for; a plain one by default.
|
||||
const close = () => {
|
||||
const closable = props.tab.closable
|
||||
|
||||
if (closable === false) return undefined
|
||||
|
||||
if (closable === "compact") return compactClose()
|
||||
|
||||
return closeButton(closable === "hover")
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch fallback={<SortableTrigger {...props} content={content()} close={close()} />}>
|
||||
<Match when={props.tab.pinned}>
|
||||
<Switch fallback={<SortableTrigger {...props} content={content()} close={closeButton(false)} />}>
|
||||
<Match when={props.tab.kind === "pinned"}>
|
||||
<Tabs.Trigger
|
||||
value={props.value}
|
||||
id={props.tab.dom?.tab}
|
||||
@@ -116,24 +88,26 @@ export function PanelTrigger(props: {
|
||||
{content()}
|
||||
</Tabs.Trigger>
|
||||
</Match>
|
||||
<Match when={props.tab.draggable === false && props.tab.closable === "compact"}>
|
||||
<Match when={props.tab.kind === "fixed"}>
|
||||
<Tabs.Trigger
|
||||
value={props.value}
|
||||
id={props.tab.dom?.tab}
|
||||
onMiddleClick={() => props.onClose(props.value)}
|
||||
closeButton={compactClose()}
|
||||
closeButton={tooltip(
|
||||
<Tabs.CloseButton onClick={() => props.onClose(props.value)} aria-label={language.t("common.closeTab")} />,
|
||||
)}
|
||||
hideCloseButton
|
||||
>
|
||||
{content()}
|
||||
</Tabs.Trigger>
|
||||
</Match>
|
||||
<Match when={props.tab.draggable === false}>
|
||||
<Match when={props.tab.kind === "launcher"}>
|
||||
<Tabs.Trigger
|
||||
value={props.value}
|
||||
id={props.tab.dom?.tab}
|
||||
class="group"
|
||||
onMiddleClick={props.tab.closable === false ? undefined : () => props.onClose(props.value)}
|
||||
closeButton={close()}
|
||||
onMiddleClick={() => props.onClose(props.value)}
|
||||
closeButton={closeButton(true)}
|
||||
hideCloseButton
|
||||
>
|
||||
{content()}
|
||||
@@ -150,7 +124,7 @@ function SortableTrigger(props: {
|
||||
active: boolean
|
||||
preview: boolean
|
||||
content: JSX.Element
|
||||
close: JSX.Element | undefined
|
||||
close: JSX.Element
|
||||
onClose: (value: string) => void
|
||||
onPromote: (value: string) => void
|
||||
}): JSX.Element {
|
||||
@@ -162,7 +136,6 @@ function SortableTrigger(props: {
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={sortable.ref} class="h-full flex items-center">
|
||||
<div class="relative">
|
||||
@@ -171,7 +144,7 @@ function SortableTrigger(props: {
|
||||
id={props.tab.dom?.tab}
|
||||
aria-controls={props.active ? props.tab.dom?.panel : undefined}
|
||||
aria-label={props.tab.missing ? props.tab.title : undefined}
|
||||
onMiddleClick={props.tab.closable === false ? undefined : () => props.onClose(props.value)}
|
||||
onMiddleClick={() => props.onClose(props.value)}
|
||||
onDblClick={() => {
|
||||
if (props.preview) props.onPromote(props.value)
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, For, on, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, on, onMount, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PanelFrame,
|
||||
type PanelSidebar,
|
||||
type PanelTab,
|
||||
type MountedSession,
|
||||
type SessionView,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
@@ -24,8 +24,6 @@ import { createSizing } from "@/session/helpers"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { useExtensionHost } from "./host"
|
||||
import { Contribution } from "./render"
|
||||
import { useExtensionAttachment } from "./attachment"
|
||||
import { legacyKeys, panelKey } from "./panel-keys"
|
||||
|
||||
type Tabs = Accessor<{
|
||||
all(): string[]
|
||||
@@ -43,6 +41,10 @@ export type RegionEntry = {
|
||||
readonly provider: Panel
|
||||
}
|
||||
|
||||
export function panelKey(extension: string, id: string) {
|
||||
return `${extension}:${id}`
|
||||
}
|
||||
|
||||
const SidebarState = Persistence.struct({
|
||||
sidebarOpened: Schema.Boolean,
|
||||
sidebarWidth: Schema.Finite.check(
|
||||
@@ -51,18 +53,14 @@ const SidebarState = Persistence.struct({
|
||||
expandMode: Schema.Literals(["expand", "collapse"]),
|
||||
})
|
||||
|
||||
/**
|
||||
* The inner sidebar preference every side panel shares. The key predates extensions. `Layout.sidebar` reads it while
|
||||
* the session screen that created it is mounted.
|
||||
*/
|
||||
/** The inner sidebar preference every side panel shares. The key predates extensions. */
|
||||
export function createPanelSidebar(): PanelSidebar {
|
||||
const [store, setStore, , ready] = persisted(Persist.global("review-panel-v2"), SidebarState, {
|
||||
sidebarOpened: true,
|
||||
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
|
||||
expandMode: "collapse",
|
||||
})
|
||||
|
||||
const sidebar: PanelSidebar = {
|
||||
return {
|
||||
opened: () => store.sidebarOpened,
|
||||
width: () => store.sidebarWidth,
|
||||
transition: ready,
|
||||
@@ -73,27 +71,18 @@ export function createPanelSidebar(): PanelSidebar {
|
||||
),
|
||||
toggle: () => setStore("sidebarOpened", (opened) => !opened),
|
||||
}
|
||||
|
||||
onCleanup(useExtensionAttachment().sidebar(sidebar))
|
||||
|
||||
return sidebar
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel extensions offer in one region of the routed session, merged with the stored strip. `view` returns the
|
||||
* routed session's object, a new one per routed session.
|
||||
*/
|
||||
export function createRegion(input: { region: Panel["region"]; view: Accessor<MountedSession>; tabs: Tabs }) {
|
||||
/** Every panel extensions offer in one region of the routed session, merged with the stored strip. */
|
||||
export function createRegion(input: { region: Panel["region"]; view: SessionView; tabs: Tabs }) {
|
||||
const host = useExtensionHost()
|
||||
const stored = () => input.tabs().all()
|
||||
const providers = createMemo(() => host.items(Panel).filter((item) => item.value.region === input.region))
|
||||
|
||||
const entries = createMemo(() =>
|
||||
providers().flatMap((item) => {
|
||||
const prefix = `${item.extension}:`
|
||||
const open = stored().flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
|
||||
|
||||
return item.value.list(input.view(), open).map(
|
||||
return item.value.list(input.view, open).map(
|
||||
(tab): RegionEntry => ({
|
||||
key: panelKey(item.extension, tab.id),
|
||||
extension: item.extension,
|
||||
@@ -103,29 +92,25 @@ export function createRegion(input: { region: Panel["region"]; view: Accessor<Mo
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const byKey = createMemo(() => new Map(entries().map((entry) => [entry.key, entry])))
|
||||
|
||||
// Rewrites stored keys once their panel is present: keys stored before extensions (e.g. "context") or under an
|
||||
// extension's earlier id, and ids a panel writes more than one way. Duplicates collapse, so one file stored two ways
|
||||
// is one tab.
|
||||
// Rewrites stored keys once their panel is present: keys stored before extensions (e.g. "context") and ids a
|
||||
// panel writes more than one way. Duplicates collapse, so one file stored two ways is one tab.
|
||||
createEffect(() => {
|
||||
const legacy = legacyKeys(providers())
|
||||
const legacy = new Map(
|
||||
providers().flatMap((item) =>
|
||||
Object.entries(item.value.legacy ?? {}).map(([key, id]) => [key, panelKey(item.extension, id)] as const),
|
||||
),
|
||||
)
|
||||
const normalizers = providers().flatMap((item) => (item.value.normalize ? [item] : []))
|
||||
|
||||
if (legacy.size === 0 && normalizers.length === 0) return
|
||||
|
||||
const rewrite = (key: string) => {
|
||||
const moved = legacy.get(key)
|
||||
|
||||
if (moved) return moved
|
||||
const item = normalizers.find((provider) => key.startsWith(`${provider.extension}:`))
|
||||
|
||||
if (!item?.value.normalize) return key
|
||||
|
||||
return panelKey(item.extension, item.value.normalize(key.slice(item.extension.length + 1), input.view()))
|
||||
return panelKey(item.extension, item.value.normalize(key.slice(item.extension.length + 1), input.view))
|
||||
}
|
||||
|
||||
// remap reads the stored tabs and writes nothing once every key is canonical, so this settles in one rerun.
|
||||
input.tabs().remap(rewrite)
|
||||
})
|
||||
@@ -134,36 +119,30 @@ export function createRegion(input: { region: Panel["region"]; view: Accessor<Mo
|
||||
createEffect(() => {
|
||||
const listed = new Set(entries().map((entry) => entry.key))
|
||||
const transient = providers().flatMap((item) => (item.value.transient ? [`${item.extension}:`] : []))
|
||||
|
||||
if (transient.length === 0) return
|
||||
const all = stored()
|
||||
const next = all.filter((key) => listed.has(key) || !transient.some((prefix) => key.startsWith(prefix)))
|
||||
|
||||
if (next.length !== all.length) input.tabs().setAll(next)
|
||||
})
|
||||
|
||||
const strip = createMemo(() => {
|
||||
const listed = stored().flatMap((key) => {
|
||||
const entry = byKey().get(key)
|
||||
|
||||
return entry && !entry.tab.pinned ? [entry] : []
|
||||
return entry && entry.tab.kind !== "pinned" ? [entry] : []
|
||||
})
|
||||
|
||||
return [
|
||||
...entries().filter((entry) => entry.tab.pinned),
|
||||
...entries().filter((entry) => entry.tab.kind === "pinned"),
|
||||
...listed.filter((entry) => entry.tab.first),
|
||||
...listed.filter((entry) => !entry.tab.first),
|
||||
]
|
||||
})
|
||||
|
||||
// Narrow screens never select a transient tab: a stored one falls back like a missing tab.
|
||||
// Narrow screens never select a launcher: a stored one falls back like a missing tab.
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const active = createMemo(() => {
|
||||
const value = input.tabs().active()
|
||||
|
||||
if (value && strip().some((entry) => entry.key === value && (desktop() || !entry.tab.transient))) return value
|
||||
|
||||
if (value && strip().some((entry) => entry.key === value && (desktop() || entry.tab.kind !== "launcher")))
|
||||
return value
|
||||
return strip()
|
||||
.filter((entry) => entry.tab.fallback !== undefined)
|
||||
.reduce<RegionEntry | undefined>(
|
||||
@@ -176,9 +155,7 @@ export function createRegion(input: { region: Panel["region"]; view: Accessor<Mo
|
||||
createEffect(
|
||||
on(active, (key, _, restored: boolean = true) => {
|
||||
const entry = key ? byKey().get(key) : undefined
|
||||
|
||||
if (entry) entry.provider.focus?.(entry.tab, input.view(), { restored })
|
||||
|
||||
if (entry) entry.provider.focus?.(entry.tab, input.view, { restored })
|
||||
return false
|
||||
}),
|
||||
)
|
||||
@@ -194,22 +171,20 @@ export function createRegion(input: { region: Panel["region"]; view: Accessor<Mo
|
||||
active,
|
||||
selected: createMemo(() => {
|
||||
const key = active()
|
||||
|
||||
return key ? byKey().get(key) : undefined
|
||||
}),
|
||||
wide: createMemo(() => providers().some((item) => item.value.wide)),
|
||||
/** An extension's tab ids in the stored strip. */
|
||||
openFor: (extension: string) =>
|
||||
stored().flatMap((key) => (key.startsWith(`${extension}:`) ? [key.slice(extension.length + 1)] : [])),
|
||||
lead: () => !!drawn().find((entry) => !entry.tab.pinned)?.tab.first,
|
||||
lead: () => !!drawn().find((entry) => entry.tab.kind !== "pinned")?.tab.first,
|
||||
select(key: string) {
|
||||
input.tabs().setActive(key)
|
||||
},
|
||||
close(key: string) {
|
||||
const entry = byKey().get(key)
|
||||
input.tabs().close(key)
|
||||
|
||||
if (entry) entry.provider.close?.(entry.tab, input.view())
|
||||
if (entry) entry.provider.close?.(entry.tab, input.view)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -219,39 +194,31 @@ export type Region = ReturnType<typeof createRegion>
|
||||
/** Grouped content stays mounted while any member is listed; other tabs mount only while selected. */
|
||||
export function RegionContent(props: {
|
||||
region: Region
|
||||
view: MountedSession
|
||||
view: SessionView
|
||||
frame: Omit<PanelFrame, "visible" | "open"> & { shown: Accessor<boolean> }
|
||||
}) {
|
||||
const groups = createMemo(() =>
|
||||
Array.from(new Set(props.region.entries().flatMap((entry) => (entry.tab.group ? [groupKey(entry)] : [])))),
|
||||
)
|
||||
|
||||
const single = createMemo(() => {
|
||||
const entry = props.region.selected()
|
||||
|
||||
return entry && !entry.tab.group ? entry.key : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={groups()}>
|
||||
{(group) => {
|
||||
const active = () => {
|
||||
const entry = props.region.selected()
|
||||
|
||||
return !!entry && groupKey(entry) === group
|
||||
}
|
||||
|
||||
// The last selected member keeps rendering while the group is hidden.
|
||||
const member = createMemo<RegionEntry | undefined>((previous) => {
|
||||
const members = props.region.entries().filter((entry) => groupKey(entry) === group)
|
||||
const selected = props.region.selected()
|
||||
|
||||
if (selected && groupKey(selected) === group) return selected
|
||||
|
||||
return members.find((entry) => entry.key === previous?.key) ?? members[0]
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={member()?.extension} keyed>
|
||||
{(extension) => (
|
||||
@@ -272,16 +239,7 @@ export function RegionContent(props: {
|
||||
}}
|
||||
>
|
||||
<Contribution extension={extension}>
|
||||
{() =>
|
||||
member()!.provider.render({
|
||||
get tab() {
|
||||
return member()!.tab
|
||||
},
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
})
|
||||
}
|
||||
{() => member()!.provider.render(() => member()!.tab, props.view)}
|
||||
</Contribution>
|
||||
</PanelContext.Provider>
|
||||
</div>
|
||||
@@ -293,7 +251,6 @@ export function RegionContent(props: {
|
||||
<Show when={single()} keyed>
|
||||
{(key) => {
|
||||
const entry = createMemo(() => props.region.entries().find((item) => item.key === key))
|
||||
|
||||
return (
|
||||
<Show when={entry()?.extension} keyed>
|
||||
{(extension) => (
|
||||
@@ -309,16 +266,7 @@ export function RegionContent(props: {
|
||||
value={{ ...props.frame, visible: props.frame.shown, open: () => props.region.openFor(extension) }}
|
||||
>
|
||||
<Contribution extension={extension}>
|
||||
{() =>
|
||||
entry()!.provider.render({
|
||||
get tab() {
|
||||
return entry()!.tab
|
||||
},
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
})
|
||||
}
|
||||
{() => entry()!.provider.render(() => entry()!.tab, props.view)}
|
||||
</Contribution>
|
||||
</PanelContext.Provider>
|
||||
</div>
|
||||
@@ -337,7 +285,7 @@ function groupKey(entry: RegionEntry) {
|
||||
|
||||
/** The dock region: host frame, sizing, and resize around the dock panel an extension renders. */
|
||||
export function DockRegion(props: {
|
||||
view: MountedSession
|
||||
view: SessionView
|
||||
sidebar: PanelSidebar
|
||||
stacked?: boolean
|
||||
fill?: boolean
|
||||
@@ -352,11 +300,9 @@ export function DockRegion(props: {
|
||||
const { view } = useSessionLayout()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
viewport: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const entry = createMemo(() =>
|
||||
host
|
||||
.items(Panel)
|
||||
@@ -373,27 +319,20 @@ export function DockRegion(props: {
|
||||
)
|
||||
.at(0),
|
||||
)
|
||||
|
||||
const opened = createMemo(() => view().dock.opened())
|
||||
const height = createMemo(() => view().dock.height())
|
||||
const max = () => store.viewport * 0.6
|
||||
const regionHeight = () => Math.min(height(), max())
|
||||
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() ? `${regionHeight()}px` : "100%"
|
||||
|
||||
return `${regionHeight()}px`
|
||||
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
|
||||
return `${pane()}px`
|
||||
})
|
||||
|
||||
const contentHeight = createMemo(
|
||||
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${regionHeight()}px` : "100%") : `${regionHeight()}px`),
|
||||
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
|
||||
)
|
||||
|
||||
const present = createMemo(() => opened() || !!props.present)
|
||||
let root: HTMLElement | undefined
|
||||
|
||||
@@ -401,16 +340,13 @@ export function DockRegion(props: {
|
||||
const sync = () => setStore("viewport", window.visualViewport?.height ?? window.innerHeight)
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
|
||||
if (window.visualViewport) makeEventListener(window.visualViewport, "resize", sync)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (opened()) return
|
||||
const active = document.activeElement
|
||||
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
@@ -440,7 +376,7 @@ export function DockRegion(props: {
|
||||
<ResizeHandle
|
||||
class="-top-1"
|
||||
direction="vertical"
|
||||
size={regionHeight()}
|
||||
size={pane()}
|
||||
min={100}
|
||||
max={max()}
|
||||
collapseThreshold={50}
|
||||
@@ -474,16 +410,7 @@ export function DockRegion(props: {
|
||||
}}
|
||||
>
|
||||
<Contribution extension={extension}>
|
||||
{() =>
|
||||
entry()!.provider.render({
|
||||
get tab() {
|
||||
return entry()!.tab
|
||||
},
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
})
|
||||
}
|
||||
{() => entry()!.provider.render(() => entry()!.tab, props.view)}
|
||||
</Contribution>
|
||||
</PanelContext.Provider>
|
||||
)}
|
||||
@@ -496,7 +423,7 @@ export function DockRegion(props: {
|
||||
/** Renders one panel as a narrow-screen view. */
|
||||
export function MobilePanel(props: {
|
||||
entry: RegionEntry
|
||||
view: MountedSession
|
||||
view: SessionView
|
||||
sidebar: PanelSidebar
|
||||
visible: boolean
|
||||
open: Accessor<readonly string[]>
|
||||
@@ -514,16 +441,7 @@ export function MobilePanel(props: {
|
||||
}}
|
||||
>
|
||||
<Contribution extension={props.entry.extension}>
|
||||
{() =>
|
||||
props.entry.provider.render({
|
||||
get tab() {
|
||||
return props.entry.tab
|
||||
},
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
})
|
||||
}
|
||||
{() => props.entry.provider.render(() => props.entry.tab, props.view)}
|
||||
</Contribution>
|
||||
</PanelContext.Provider>
|
||||
)
|
||||
|
||||
+14
-19
@@ -1,62 +1,58 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Ipc } from "@opencode/gui-extensions/sdk"
|
||||
import type { Remote } from "@opencode/gui-extensions/sdk"
|
||||
import type { Bridge, BridgeMessage } from "@opencode/gui-extensions/sdk/bridge"
|
||||
import { createIpcClients } from "./ipc"
|
||||
|
||||
const token: Ipc = { kind: "ipc", id: "fixture", spec: { id: "fixture", methods: {} } }
|
||||
import { createRemotes } from "./remote"
|
||||
|
||||
const token: Remote = { kind: "remote", id: "fixture", spec: { id: "fixture", methods: {} } }
|
||||
type Reply = { readonly available: boolean; readonly state?: unknown }
|
||||
|
||||
// Each row sends an event while the subscribe reply is in flight; the older reply must not undo it.
|
||||
test.each<[string, BridgeMessage, Reply, { available: boolean; state: unknown }]>([
|
||||
[
|
||||
"the Ipc appearing",
|
||||
{ type: "available", ipc: "fixture", available: true },
|
||||
"the remote appearing",
|
||||
{ type: "available", remote: "fixture", available: true },
|
||||
{ available: false },
|
||||
{ available: true, state: undefined },
|
||||
],
|
||||
[
|
||||
"the Ipc going away",
|
||||
{ type: "available", ipc: "fixture", available: false },
|
||||
"the remote going away",
|
||||
{ type: "available", remote: "fixture", available: false },
|
||||
{ available: true, state: { count: 1 } },
|
||||
{ available: false, state: undefined },
|
||||
],
|
||||
[
|
||||
"a state push",
|
||||
{ type: "state", ipc: "fixture", state: { count: 2 } },
|
||||
{ type: "state", remote: "fixture", state: { count: 2 } },
|
||||
{ available: true, state: { count: 1 } },
|
||||
{ available: true, state: { count: 2 } },
|
||||
],
|
||||
])("a subscribe reply older than %s keeps the event's data", async (_, event, reply, expected) => {
|
||||
const bridge = fakeBridge()
|
||||
const ipcs = createIpcClients(bridge.value)
|
||||
expect(ipcs.client(token)).toBeUndefined()
|
||||
const remotes = createRemotes(bridge.value)
|
||||
expect(remotes.client(token)).toBeUndefined()
|
||||
bridge.emit(event)
|
||||
bridge.reply(reply)
|
||||
await Bun.sleep(0)
|
||||
const client = ipcs.client(token)
|
||||
const client = remotes.client(token)
|
||||
expect({ available: !!client, state: client?.state() }).toEqual(expected)
|
||||
ipcs.dispose()
|
||||
remotes.dispose()
|
||||
})
|
||||
|
||||
function fakeBridge() {
|
||||
const listeners = new Set<(message: BridgeMessage) => void>()
|
||||
const pending = Promise.withResolvers<Reply>()
|
||||
|
||||
const value: Bridge = {
|
||||
packaged: false,
|
||||
call: () => Promise.reject(new Error("no calls in this test")),
|
||||
subscribe: () => pending.promise,
|
||||
on: (listener) => {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
embed: () => {},
|
||||
surface: () => {},
|
||||
capture: async () => undefined,
|
||||
runMenubarItem: () => {},
|
||||
menubar: () => {},
|
||||
configure: () => {},
|
||||
manager: {
|
||||
list: async () => [],
|
||||
@@ -69,7 +65,6 @@ function fakeBridge() {
|
||||
asset: () => "",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
emit: (message: BridgeMessage) => listeners.forEach((listener) => listener(message)),
|
||||
@@ -0,0 +1,119 @@
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import type { Remote, RemoteClient, RemoteSpec } from "@opencode/gui-extensions/sdk"
|
||||
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
/** Renderer-side clients for remotes that extensions provide in the main process. */
|
||||
export function createRemotes(bridge: Bridge | undefined) {
|
||||
const [state, setState] = createStore({
|
||||
available: {} as Record<string, boolean | undefined>,
|
||||
values: {} as Record<string, unknown>,
|
||||
})
|
||||
const specs = new Map<string, RemoteSpec>()
|
||||
const clients = new Map<string, RemoteClient<RemoteSpec>>()
|
||||
const listeners = new Map<string, Set<(name: string, data: unknown) => void>>()
|
||||
const subscribed = new Set<string>()
|
||||
// How many availability and state events reached each remote. Events are newer than any snapshot, so a
|
||||
// subscribe reply only fills in what no event changed while it was in flight.
|
||||
const changes = new Map<string, { available: number; state: number }>()
|
||||
const changesOf = (id: string) => {
|
||||
const existing = changes.get(id)
|
||||
if (existing) return existing
|
||||
const created = { available: 0, state: 0 }
|
||||
changes.set(id, created)
|
||||
return created
|
||||
}
|
||||
|
||||
const decodeState = (id: string, value: unknown) => {
|
||||
const schema = specs.get(id)?.state
|
||||
return schema ? Schema.decodeUnknownSync(schema)(value) : value
|
||||
}
|
||||
|
||||
const stop = bridge?.on((message) => {
|
||||
if (message.type === "state") {
|
||||
if (!specs.has(message.remote)) return
|
||||
changesOf(message.remote).state++
|
||||
setState("values", message.remote, reconcile(decodeState(message.remote, message.state)))
|
||||
return
|
||||
}
|
||||
if (message.type === "available") {
|
||||
const changed = changesOf(message.remote)
|
||||
changed.available++
|
||||
// Going away clears the state too.
|
||||
if (!message.available) changed.state++
|
||||
batch(() => {
|
||||
setState("available", message.remote, message.available)
|
||||
if (!message.available) setState("values", message.remote, undefined)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type !== "event") return
|
||||
const schema = specs.get(message.remote)?.events?.[message.name]
|
||||
const data = schema ? Schema.decodeUnknownSync(schema)(message.data) : message.data
|
||||
listeners.get(message.remote)?.forEach((listener) => listener(message.name, data))
|
||||
})
|
||||
|
||||
const subscribe = (connected: Bridge, token: Remote) => {
|
||||
if (subscribed.has(token.id)) return
|
||||
subscribed.add(token.id)
|
||||
specs.set(token.id, token.spec)
|
||||
const before = { ...changesOf(token.id) }
|
||||
void connected.subscribe(token.id).then((result) => {
|
||||
const after = changesOf(token.id)
|
||||
batch(() => {
|
||||
if (after.available === before.available) setState("available", token.id, result.available)
|
||||
if (after.state === before.state && result.state !== undefined)
|
||||
setState("values", token.id, decodeState(token.id, result.state))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const create = (connected: Bridge, token: Remote): RemoteClient<RemoteSpec> => {
|
||||
const methods = Object.fromEntries(
|
||||
Object.entries(token.spec.methods).map(([name, method]) => [
|
||||
name,
|
||||
async (input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
const encoded = method.input ? Schema.encodeUnknownSync(method.input)(input) : null
|
||||
const output = await connected.call({ remote: token.id, method: name, input: encoded }, options?.signal)
|
||||
return method.output ? Schema.decodeUnknownSync(method.output)(output) : undefined
|
||||
},
|
||||
]),
|
||||
)
|
||||
return Object.assign(methods, {
|
||||
state: () => state.values[token.id],
|
||||
on(name: string, listener: (data: unknown) => void) {
|
||||
const set = listeners.get(token.id) ?? new Set()
|
||||
const wrapped = (event: string, data: unknown) => {
|
||||
if (event === name) listener(data)
|
||||
}
|
||||
set.add(wrapped)
|
||||
listeners.set(token.id, set)
|
||||
return () => {
|
||||
set.delete(wrapped)
|
||||
}
|
||||
},
|
||||
}) as unknown as RemoteClient<RemoteSpec>
|
||||
}
|
||||
|
||||
const client = (token: Remote) => {
|
||||
if (!bridge) return undefined
|
||||
subscribe(bridge, token)
|
||||
if (!state.available[token.id]) return undefined
|
||||
const existing = clients.get(token.id)
|
||||
if (existing) return existing
|
||||
const created = create(bridge, token)
|
||||
clients.set(token.id, created)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
/** A client typed by its token, for host code that uses one remote directly. */
|
||||
typed: <S extends RemoteSpec>(token: Remote<S>) => client(token) as RemoteClient<S> | undefined,
|
||||
/** Stops listening to the bridge. Call it when the owner of these clients goes away. */
|
||||
dispose() {
|
||||
stop?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import { createMemo, ErrorBoundary, For, onMount, Show, untrack, type JSX, type ParentProps } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { ExtensionContext, Slot, Style, type MountedSession, type SlotMap } from "@opencode/gui-extensions/sdk"
|
||||
import { ExtensionContext, Slot, Style, type SessionView, type SlotMap } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "./host"
|
||||
|
||||
/**
|
||||
* Renders one extension contribution with its context and error isolation: a contribution that throws records the
|
||||
* error and renders nothing, and the rest of the window keeps working. Contributions never render on timeline rows;
|
||||
* the session header slot is per timeline.
|
||||
*/
|
||||
/** Renders one extension contribution with its context and error isolation. */
|
||||
export function Contribution(props: { extension: string; children: () => JSX.Element }) {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return (
|
||||
<Show when={host.context(props.extension)} keyed>
|
||||
{(context) => (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
onMount(() => host.fail(props.extension, error, "render"))
|
||||
|
||||
onMount(() => host.fail(props.extension, error))
|
||||
return null
|
||||
}}
|
||||
>
|
||||
@@ -32,22 +26,17 @@ export function Contribution(props: { extension: string; children: () => JSX.Ele
|
||||
|
||||
export function ExtensionSlot<At extends keyof SlotMap>(props: { at: At; input: SlotMap[At] }) {
|
||||
const host = useExtensionHost()
|
||||
|
||||
const items = createMemo(() =>
|
||||
host
|
||||
.items(Slot)
|
||||
.filter((item) => item.value.at === props.at)
|
||||
.toSorted((a, b) => (a.value.order ?? 0) - (b.value.order ?? 0)),
|
||||
)
|
||||
|
||||
return (
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Contribution extension={item.extension}>
|
||||
{() =>
|
||||
// SAFETY: items are filtered to `at === props.at`, and a slot's render takes that slot's input.
|
||||
(item.value.render as (input: SlotMap[At]) => JSX.Element)(props.input)
|
||||
}
|
||||
{() => (item.value.render as (input: SlotMap[At]) => JSX.Element)(props.input)}
|
||||
</Contribution>
|
||||
)}
|
||||
</For>
|
||||
@@ -56,7 +45,6 @@ export function ExtensionSlot<At extends keyof SlotMap>(props: { at: At; input:
|
||||
|
||||
export function ExtensionStyles() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return (
|
||||
<Portal mount={document.head}>
|
||||
<For each={host.items(Style)}>{(item) => <style data-extension={item.extension}>{item.value}</style>}</For>
|
||||
@@ -64,11 +52,10 @@ export function ExtensionStyles() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Routes local markdown links in the session to extension LinkHandler contributions, keeping image loading. */
|
||||
export function ExtensionLinks(props: ParentProps<{ session: MountedSession }>) {
|
||||
/** Routes local markdown links in the session to extension Link handlers, keeping image loading. */
|
||||
export function ExtensionLinks(props: ParentProps<{ session: SessionView }>) {
|
||||
const host = useExtensionHost()
|
||||
const markdown = useMarkdown()
|
||||
|
||||
return (
|
||||
<MarkdownProvider
|
||||
readImage={markdown?.readImage}
|
||||
|
||||
@@ -1,65 +1,54 @@
|
||||
import { createMemo, lazy, onCleanup, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import type { Definition } from "@opencode/gui-extensions/sdk"
|
||||
import { builtins } from "./builtins"
|
||||
import { createInstalled } from "./installed"
|
||||
import { createMenubarItems, MenubarItemsProvider } from "./menubar-items"
|
||||
import { createMenubar, ExtensionMenubarProvider } from "./menubar"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExtensionHostProvider, useExtensionHost } from "./host"
|
||||
import { createIpcClients } from "./ipc"
|
||||
import { createExtensionAttachment, createHostApis, ExtensionAttachmentProvider, type HostApis } from "./host-apis"
|
||||
import { createRemotes } from "./remote"
|
||||
import {
|
||||
createExtensionAttachment,
|
||||
createExtensionServices,
|
||||
ExtensionAttachmentProvider,
|
||||
type ExtensionServices,
|
||||
} from "./services"
|
||||
import { ExtensionCommands } from "./commands"
|
||||
import { ExtensionStyles } from "./render"
|
||||
import { ExtensionServersProvider } from "./servers"
|
||||
import { ExtensionServerEndpoints } from "./server-shell"
|
||||
import { createContext, useContext } from "solid-js"
|
||||
|
||||
const HostApisContext = createContext<HostApis>()
|
||||
|
||||
const ServicesContext = createContext<ExtensionServices>()
|
||||
const ExtensionHotReload = import.meta.env.DEV ? lazy(() => import("./hmr")) : undefined
|
||||
|
||||
export function useHostApis() {
|
||||
const value = useContext(HostApisContext)
|
||||
|
||||
if (!value) throw new Error("Host APIs are unavailable")
|
||||
|
||||
export function useExtensionServices() {
|
||||
const value = useContext(ServicesContext)
|
||||
if (!value) throw new Error("Extension services are unavailable")
|
||||
return value
|
||||
}
|
||||
|
||||
/** Mounts the extension host for the window. Lives above the app interface so extensions can contribute servers. */
|
||||
export function ExtensionRoot(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const apis = createHostApis()
|
||||
const services = createExtensionServices()
|
||||
const bridge = platform.extensions
|
||||
const menubar = createMenubarItems(bridge)
|
||||
const ipcs = createIpcClients(bridge)
|
||||
onCleanup(ipcs.dispose)
|
||||
const menubar = createMenubar(bridge)
|
||||
const remotes = createRemotes(bridge)
|
||||
onCleanup(remotes.dispose)
|
||||
const installed = createInstalled(bridge)
|
||||
|
||||
const disabled = createMemo(() => {
|
||||
if (!installed.loaded()) return undefined
|
||||
|
||||
return new Set(installed.list().flatMap((item) => (item.enabled ? [] : [item.id])))
|
||||
})
|
||||
|
||||
const os = platform.platform === "desktop" ? platform.os : undefined
|
||||
|
||||
// Built-ins only: installed `.ocdx` archives run their main entry until that format ships renderer bundles.
|
||||
const definitions = builtins.filter(
|
||||
(definition: Definition) => !definition.os || (!!os && definition.os.includes(os)),
|
||||
)
|
||||
|
||||
const failed = (id: string) => installed.list().some((item) => item.id === id && item.error !== undefined)
|
||||
|
||||
const definitions = builtins.filter((definition) => !definition.os || (!!os && definition.os.includes(os)))
|
||||
return (
|
||||
<HostApisContext.Provider value={apis}>
|
||||
<ServicesContext.Provider value={services}>
|
||||
<ExtensionHostProvider
|
||||
definitions={definitions}
|
||||
disabled={disabled}
|
||||
apis={apis.apis}
|
||||
whenMounted={apis.whenMounted}
|
||||
ipc={bridge ? (token) => ipcs.client(token) : undefined}
|
||||
generation={ipcs.generation}
|
||||
failed={failed}
|
||||
services={services.services}
|
||||
remote={(token) => remotes.client(token)}
|
||||
>
|
||||
<ExtensionStyles />
|
||||
{ExtensionHotReload && (
|
||||
@@ -67,37 +56,27 @@ export function ExtensionRoot(props: ParentProps) {
|
||||
<ExtensionHotReload />
|
||||
</Suspense>
|
||||
)}
|
||||
<MenubarItemsProvider value={menubar}>
|
||||
<ExtensionServersProvider failed={failed}>{props.children}</ExtensionServersProvider>
|
||||
</MenubarItemsProvider>
|
||||
<ExtensionMenubarProvider value={menubar}>
|
||||
<ExtensionServersProvider
|
||||
failed={(id) => installed.list().some((item) => item.id === id && item.error !== undefined)}
|
||||
>
|
||||
{props.children}
|
||||
</ExtensionServersProvider>
|
||||
</ExtensionMenubarProvider>
|
||||
</ExtensionHostProvider>
|
||||
</HostApisContext.Provider>
|
||||
</ServicesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Attaches the session and layout HostApis and publishes extension commands. Renders once extensions are active. */
|
||||
/** Attaches session and layout services and publishes extension commands. Renders once extensions are active. */
|
||||
export function ExtensionAttachment(props: ParentProps) {
|
||||
const host = useExtensionHost()
|
||||
const attachment = createExtensionAttachment(useHostApis())
|
||||
|
||||
const attachment = createExtensionAttachment(useExtensionServices())
|
||||
return (
|
||||
<ExtensionAttachmentProvider value={attachment}>
|
||||
<ExtensionCommands />
|
||||
<ExtensionServerEndpoints />
|
||||
<Show when={host.ready()}>
|
||||
<MountedInterface attach={attachment.attach} />
|
||||
{props.children}
|
||||
</Show>
|
||||
<Show when={host.ready()}>{props.children}</Show>
|
||||
</ExtensionAttachmentProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app interface as the HostApis see it, mounted as the routes first render and not before: writes and dialogs made
|
||||
* while extensions set up wait until then.
|
||||
*/
|
||||
function MountedInterface(props: { attach: () => () => void }) {
|
||||
onCleanup(props.attach())
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, createEffect, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||
import { reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { MenuItem, Server, type ServerEntry } from "@opencode/gui-extensions/sdk"
|
||||
import { Menu, Server, type ServerEntry } from "@opencode/gui-extensions/sdk"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -35,9 +35,7 @@ const ServersContext = createContext<ReturnType<typeof createExtensionServers>>(
|
||||
|
||||
export function useExtensionServers() {
|
||||
const value = useContext(ServersContext)
|
||||
|
||||
if (!value) throw new Error("Extension servers are unavailable")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -57,7 +55,6 @@ function createExtensionServers(failed: (extension: string) => boolean) {
|
||||
const available = createMemo(() => sources().filter((item) => item.value.ready))
|
||||
createEffect(() => {
|
||||
if (!knownReady()) return
|
||||
|
||||
for (const item of available()) {
|
||||
const inventory = {
|
||||
order: item.value.order ?? 0,
|
||||
@@ -68,16 +65,13 @@ function createExtensionServers(failed: (extension: string) => boolean) {
|
||||
listed: entry.listed,
|
||||
})),
|
||||
}
|
||||
|
||||
if (JSON.stringify(known[item.extension]) !== JSON.stringify(inventory))
|
||||
setKnown(item.extension, reconcile(inventory))
|
||||
}
|
||||
})
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const live = available()
|
||||
const down = Object.entries(known).filter(([extension]) => !live.some((item) => item.extension === extension))
|
||||
|
||||
return [
|
||||
...live.map((item) => ({ extension: item.extension, order: item.value.order ?? 0, entries: item.value.entries })),
|
||||
...down.map(([extension, inventory]) => ({
|
||||
@@ -93,37 +87,29 @@ function createExtensionServers(failed: (extension: string) => boolean) {
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
// Routes key on the connection object. Keep one per key across entry updates so reconnecting
|
||||
// never unmounts an open conversation or composer.
|
||||
const connections = new Map<
|
||||
string,
|
||||
{ update: (server: ExtensionServer) => void; value: ServerConnection.Extension }
|
||||
>()
|
||||
|
||||
const list = createMemo(() => {
|
||||
const listed = entries().filter((item) => item.entry.listed !== false)
|
||||
const keys = new Set(listed.map((item) => item.key))
|
||||
connections.forEach((_, key) => {
|
||||
if (!keys.has(key)) connections.delete(key)
|
||||
})
|
||||
|
||||
return listed.map((item) => {
|
||||
const existing = connections.get(item.key)
|
||||
|
||||
if (existing) {
|
||||
existing.update(item)
|
||||
|
||||
return existing.value
|
||||
}
|
||||
|
||||
const created = connection(item)
|
||||
connections.set(item.key, created)
|
||||
|
||||
return created.value
|
||||
})
|
||||
})
|
||||
|
||||
// Startup waits for every source once; a source that reloads later does not hide the app again.
|
||||
const ready = createMemo<boolean>(
|
||||
(previous) =>
|
||||
@@ -131,7 +117,6 @@ function createExtensionServers(failed: (extension: string) => boolean) {
|
||||
(knownReady() && host.ready() && sources().every((item) => item.value.ready || failed(item.extension))),
|
||||
false,
|
||||
)
|
||||
|
||||
return {
|
||||
ready,
|
||||
/** Contributed servers the app lists, as stable connections. */
|
||||
@@ -148,7 +133,6 @@ function createExtensionServers(failed: (extension: string) => boolean) {
|
||||
function connection(initial: ExtensionServer) {
|
||||
const [current, setCurrent] = createSignal(initial)
|
||||
const entry = () => current().entry
|
||||
|
||||
const value: ServerConnection.Extension = {
|
||||
type: "extension",
|
||||
key: initial.key,
|
||||
@@ -177,35 +161,33 @@ function connection(initial: ExtensionServer) {
|
||||
// Resolved per call: the connection outlives a reload of the extension that provides it.
|
||||
get reconnect() {
|
||||
if (!entry().reconnect) return undefined
|
||||
|
||||
return (signal: AbortSignal) => entry().reconnect?.(signal) ?? Promise.resolve(entry().http ?? offline)
|
||||
},
|
||||
connect: () => entry().connect?.() ?? Promise.resolve(entry().state === "ready"),
|
||||
}
|
||||
|
||||
return { update: (server: ExtensionServer) => setCurrent(server), value }
|
||||
}
|
||||
|
||||
/** MenuItem "server.row" contributions that apply to a server. */
|
||||
/** Menu "server.row" items that apply to a server. */
|
||||
export function useServerRowItems(server: () => string) {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return createMemo(() =>
|
||||
host
|
||||
.list(MenuItem)
|
||||
.flatMap((item) => (item.menu === "server.row" && (item.when?.(server()) ?? true) ? [item] : []))
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
.items(Menu)
|
||||
.filter((item) => item.value.menu === "server.row" && (item.value.when?.(server()) ?? true))
|
||||
.toSorted((a, b) => (a.value.order ?? 0) - (b.value.order ?? 0))
|
||||
.map((item) => item.value),
|
||||
)
|
||||
}
|
||||
|
||||
/** MenuItem "server.add" contributions, in order. */
|
||||
/** Menu "server.add" items, in order. */
|
||||
export function useServerAddItems() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return createMemo(() =>
|
||||
host
|
||||
.list(MenuItem)
|
||||
.flatMap((item) => (item.menu === "server.add" ? [item] : []))
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
.items(Menu)
|
||||
.filter((item) => item.value.menu === "server.add")
|
||||
.toSorted((a, b) => (a.value.order ?? 0) - (b.value.order ?? 0))
|
||||
.map((item) => item.value),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
on,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
untrack,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type Owner,
|
||||
} from "solid-js"
|
||||
import { createStore, produce, type Store } from "solid-js/store"
|
||||
import { Predicate } from "effect"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import {
|
||||
App,
|
||||
Layout,
|
||||
Native,
|
||||
Panel,
|
||||
Preferences,
|
||||
Sessions,
|
||||
Storage,
|
||||
Surfaces,
|
||||
System,
|
||||
type Host,
|
||||
type PanelState,
|
||||
type ServerRef,
|
||||
type SessionRef,
|
||||
type SessionView,
|
||||
type StorageScope,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { useGlobal, type ServerCtx } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { SessionRouteKey, SessionStateKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
|
||||
import { terminalFontFamily, useSettings } from "@/settings/model"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useExtensionHost } from "./host"
|
||||
import type { Region } from "./panels"
|
||||
import { createSurfaces } from "./surface"
|
||||
|
||||
type Attached = {
|
||||
sessions: Accessor<readonly SessionRef[]>
|
||||
current: Accessor<SessionView | undefined>
|
||||
scope: (server: string) => ServerScope
|
||||
/** Records a session-scoped store so layout pruning drops it with the session. */
|
||||
scoped: (name: string) => void
|
||||
layout: Omit<Layout, "narrow" | "settings" | "project" | "stored"> & {
|
||||
stored(extension: string, session: SessionRef): readonly string[]
|
||||
}
|
||||
settings: (page?: string) => void
|
||||
project: (server: string, title: string) => void
|
||||
font: Accessor<string>
|
||||
preferences: Preferences
|
||||
routing: Accessor<boolean>
|
||||
path: Accessor<string>
|
||||
keybind: (command: string) => readonly string[]
|
||||
matches: (command: string, event: KeyboardEvent) => boolean
|
||||
servers: Accessor<readonly string[]>
|
||||
}
|
||||
|
||||
export type HostService = { readonly token: Host<unknown>; create(extension: string, owner: Owner | null): unknown }
|
||||
type StorageFrom = Parameters<Storage["store"]>[1]["from"]
|
||||
|
||||
/** Services the host owns. Session and layout attach once the app interface mounts. */
|
||||
export function createExtensionServices() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
// The session screen's breakpoint, negated, so no fractional width is both narrow and desktop.
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
const [attached, setAttached] = createSignal<Attached>()
|
||||
const removed = new Set<(value: { server: string; directory: string }) => void>()
|
||||
const memory = new Map<string, readonly [Store<object>, (mutation: (draft: object) => void) => void]>()
|
||||
const current = () => attached()
|
||||
const surfaces = createSurfaces({
|
||||
bridge: platform.extensions,
|
||||
zoom: () => platform.webviewZoom?.() ?? 1,
|
||||
dialog: () => !!dialog.active,
|
||||
})
|
||||
|
||||
const target = (extension: string, key: string, scope: StorageScope | undefined, from: StorageFrom | undefined) => {
|
||||
const name = `extension.${extension}.${key}`
|
||||
const copyFrom = typeof from === "string" ? { key: from } : from
|
||||
if (!scope || scope === "app") return { ...Persist.global(name), copyFrom }
|
||||
const connected = requireAttached(attached())
|
||||
if ("session" in scope) {
|
||||
const location = scope.session.location
|
||||
if (!location) throw new Error("Session storage requires a session location")
|
||||
connected.scoped(name)
|
||||
const server = connected.scope(scope.session.server.id)
|
||||
const directory = base64Encode(location.directory)
|
||||
return {
|
||||
...Persist.serverSession(server, directory, scope.session.id, name),
|
||||
copyFrom:
|
||||
sessionCopy(from, SessionStateKey.from(server, SessionRouteKey.fromRoute(directory, scope.session.id))) ??
|
||||
copyFrom,
|
||||
}
|
||||
}
|
||||
if (!scope.directory) return { ...Persist.serverGlobal(connected.scope(scope.server), name), copyFrom }
|
||||
return { ...Persist.serverWorkspace(connected.scope(scope.server), base64Encode(scope.directory), name), copyFrom }
|
||||
}
|
||||
|
||||
const services: HostService[] = [
|
||||
{
|
||||
token: Storage,
|
||||
create: (extension, owner) =>
|
||||
({
|
||||
store(key, options) {
|
||||
// Persistence owns effects and resources; code after an await in setup has no owner.
|
||||
const pair = runWithOwner(getOwner() ?? owner, () =>
|
||||
persisted(target(extension, key, options.scope, options.from), options.schema, options.initial, platform),
|
||||
)!
|
||||
return [pair[0], (mutation: (draft: object) => void) => pair[1](produce(mutation)), pair[3]] as never
|
||||
},
|
||||
memory(key, options) {
|
||||
const name = `${extension}.${key}`
|
||||
const existing = memory.get(name)
|
||||
if (existing) return existing as never
|
||||
const [store, setStore] = createStore<object>(options.initial)
|
||||
const value = [store, (mutation: (draft: object) => void) => setStore(produce(mutation))] as const
|
||||
memory.set(name, value)
|
||||
return value as never
|
||||
},
|
||||
remove(key, options) {
|
||||
removePersisted(target(extension, key, options?.scope, undefined), platform)
|
||||
},
|
||||
}) satisfies Storage,
|
||||
},
|
||||
{
|
||||
token: System,
|
||||
create: () =>
|
||||
({
|
||||
copy: (text) => platform.writeClipboardText?.(text) ?? navigator.clipboard.writeText(text),
|
||||
async save(file) {
|
||||
if (platform.saveFile) return platform.saveFile({ defaultPath: file.name }, file.content)
|
||||
const url = URL.createObjectURL(new Blob([file.content], { type: "application/octet-stream" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = file.name
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return true
|
||||
},
|
||||
open(url) {
|
||||
if (platform.openLocalFile && URL.canParse(url) && new URL(url).protocol === "file:")
|
||||
return platform.openLocalFile(url)
|
||||
platform.openExternal(url)
|
||||
},
|
||||
}) satisfies System,
|
||||
},
|
||||
{
|
||||
token: Native,
|
||||
create: () =>
|
||||
platform.platform === "desktop"
|
||||
? ({
|
||||
os: platform.os ?? "linux",
|
||||
window: platform.windowID,
|
||||
zoom: () => platform.webviewZoom?.() ?? 1,
|
||||
launch: (path, app) => platform.openPath?.(path, app) ?? Promise.resolve(),
|
||||
reveal: (path) => platform.revealPath?.(path) ?? Promise.resolve(false),
|
||||
installed: (app) => platform.checkAppExists?.(app) ?? Promise.resolve(false),
|
||||
forceFocus: (enabled) => platform.setForceFocus?.(enabled) ?? Promise.resolve(),
|
||||
} satisfies NonNullable<Native>)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
token: App,
|
||||
create: () =>
|
||||
({
|
||||
version: platform.version,
|
||||
channel: (import.meta.env.VITE_OPENCODE_CHANNEL ?? "local") as App["channel"],
|
||||
platform: platform.platform,
|
||||
font: () => requireAttached(current()).font(),
|
||||
locale: language.intl,
|
||||
direction: language.direction,
|
||||
setDirection: language.setDirection,
|
||||
routing: () => current()?.routing() ?? false,
|
||||
path: () => current()?.path() ?? "",
|
||||
keybind: (command) => current()?.keybind(command) ?? [],
|
||||
keys: (bind) => formatKeybindParts(bind, language.t),
|
||||
matches: (command, event) => current()?.matches(command, event) ?? false,
|
||||
servers: () => current()?.servers() ?? [],
|
||||
on(_event, handler) {
|
||||
removed.add(handler)
|
||||
return () => {
|
||||
removed.delete(handler)
|
||||
}
|
||||
},
|
||||
}) satisfies App,
|
||||
},
|
||||
{
|
||||
token: Sessions,
|
||||
create: () =>
|
||||
({
|
||||
list: () => current()?.sessions() ?? [],
|
||||
current: () => current()?.current(),
|
||||
}) satisfies Sessions,
|
||||
},
|
||||
{
|
||||
token: Layout,
|
||||
create: (extension) =>
|
||||
({
|
||||
narrow,
|
||||
ready: () => current()?.layout.ready() ?? false,
|
||||
open: (key, session, options) => requireAttached(current()).layout.open(key, session, options),
|
||||
close: (key, session) => requireAttached(current()).layout.close(key, session),
|
||||
toggle: (key, session) => requireAttached(current()).layout.toggle(key, session),
|
||||
state: (key, session) => requireAttached(current()).layout.state(key, session),
|
||||
stored: (session) => requireAttached(current()).layout.stored(extension, session),
|
||||
side: {
|
||||
opened: (session) => requireAttached(current()).layout.side.opened(session),
|
||||
toggle: (session) => requireAttached(current()).layout.side.toggle(session),
|
||||
},
|
||||
dock: {
|
||||
opened: (session) => requireAttached(current()).layout.dock.opened(session),
|
||||
placement: () => requireAttached(current()).layout.dock.placement(),
|
||||
},
|
||||
scroll: {
|
||||
get: (session, key) => requireAttached(current()).layout.scroll.get(session, key),
|
||||
set: (session, key, value) => requireAttached(current()).layout.scroll.set(session, key, value),
|
||||
},
|
||||
settings: (page) => requireAttached(current()).settings(page),
|
||||
project: (server, title) => requireAttached(current()).project(server, title),
|
||||
}) satisfies Layout,
|
||||
},
|
||||
{
|
||||
token: Preferences,
|
||||
create: () =>
|
||||
({
|
||||
releaseNotes: () => requireAttached(current()).preferences.releaseNotes(),
|
||||
setReleaseNotes: (value) => requireAttached(current()).preferences.setReleaseNotes(value),
|
||||
mobileDiffWrap: () => requireAttached(current()).preferences.mobileDiffWrap(),
|
||||
}) satisfies Preferences,
|
||||
},
|
||||
{ token: Surfaces, create: () => surfaces },
|
||||
]
|
||||
|
||||
return {
|
||||
services,
|
||||
attach(value: Attached) {
|
||||
setAttached(() => value)
|
||||
return () => {
|
||||
if (attached() === value) setAttached(undefined)
|
||||
}
|
||||
},
|
||||
workspaceRemoved(value: { server: string; directory: string }) {
|
||||
removed.forEach((handler) => handler(value))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtensionServices = ReturnType<typeof createExtensionServices>
|
||||
|
||||
const AttachmentContext = createContext<ReturnType<typeof createExtensionAttachment>>()
|
||||
|
||||
export function useExtensionAttachment() {
|
||||
const value = useContext(AttachmentContext)
|
||||
if (!value) throw new Error("Extension attachment is unavailable")
|
||||
return value
|
||||
}
|
||||
|
||||
export const ExtensionAttachmentProvider = AttachmentContext.Provider
|
||||
|
||||
/** Attaches session and layout services from inside the app interface. */
|
||||
export function createExtensionAttachment(services: ExtensionServices) {
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const route = useCurrentRoute()
|
||||
const settings = useSettings()
|
||||
const surface = useSettingsSurface()
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
const location = useLocation()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
const views = new Map<string, SessionView>()
|
||||
const [mounted, setMounted] = createStore({ revision: 0 })
|
||||
const refs = new Map<string, SessionRef>()
|
||||
|
||||
const connection = (id: string) => global.servers.list().find((item) => ServerConnection.key(item) === id)
|
||||
|
||||
// One ref per server id. A restarted server (e.g. an updated WSL server) gets a new controller under the same id,
|
||||
// so the ref follows the live controller instead of the one it was created with.
|
||||
const owner = getOwner()
|
||||
const serverRefs = new Map<string, ServerRef>()
|
||||
const server = (id: string): ServerRef | undefined => {
|
||||
const conn = connection(id)
|
||||
if (!conn) return
|
||||
const existing = serverRefs.get(id)
|
||||
if (existing) return existing
|
||||
const key = ServerConnection.Key.make(id)
|
||||
const live = runWithOwner(owner, () =>
|
||||
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
|
||||
)!
|
||||
const ref: ServerRef = {
|
||||
id,
|
||||
get name() {
|
||||
return serverName(live().sdk.server) || id
|
||||
},
|
||||
get url() {
|
||||
return live().sdk.url
|
||||
},
|
||||
get password() {
|
||||
return live().sdk.server.http.password
|
||||
},
|
||||
get client() {
|
||||
return live().sdk.api
|
||||
},
|
||||
get data() {
|
||||
return live().data
|
||||
},
|
||||
get local() {
|
||||
return ServerConnection.local(live().sdk.server)
|
||||
},
|
||||
get builtin() {
|
||||
return ServerConnection.builtin(live().sdk.server)
|
||||
},
|
||||
get compatible() {
|
||||
return !global.servers.health[key]?.incompatible
|
||||
},
|
||||
get connected() {
|
||||
return live().sdk.connection.status() === "connected"
|
||||
},
|
||||
}
|
||||
serverRefs.set(id, ref)
|
||||
return ref
|
||||
}
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
|
||||
Array.from(refs).forEach(([key, ref]) => {
|
||||
if (!owned.has(ref.tab)) refs.delete(key)
|
||||
})
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "session") return
|
||||
const target = server(tab.server)
|
||||
if (!target) return
|
||||
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
|
||||
const key = `${tab.server}\n${id}`
|
||||
if (refs.has(key)) return
|
||||
refs.set(key, {
|
||||
key,
|
||||
id,
|
||||
tab: tabKey(tab),
|
||||
server: target,
|
||||
get pending() {
|
||||
return target.data.session.creating(id)
|
||||
},
|
||||
get location() {
|
||||
return target.data.session.get(id)?.location
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
return Array.from(refs.values())
|
||||
})
|
||||
|
||||
const routed = createMemo(() => {
|
||||
const value = route()
|
||||
return value.type === "session" ? `${value.server}\n${value.sessionId}` : undefined
|
||||
})
|
||||
const current = createMemo(() => {
|
||||
const key = routed()
|
||||
if (!key) return
|
||||
void mounted.revision
|
||||
return views.get(key)
|
||||
})
|
||||
|
||||
const scope = (id: string) => {
|
||||
const conn = connection(id)
|
||||
if (!conn) throw new Error(`Server ${id} is unavailable`)
|
||||
return global.ensureServerCtx(conn).sdk.scope
|
||||
}
|
||||
|
||||
const stateKey = (session: SessionRef) => {
|
||||
const location = session.location
|
||||
if (!location) return
|
||||
return SessionStateKey.from(
|
||||
scope(session.server.id),
|
||||
SessionRouteKey.fromRoute(base64Encode(location.directory), session.id),
|
||||
)
|
||||
}
|
||||
|
||||
const shellTab = (session: SessionRef) =>
|
||||
findSessionTab(tabs.store, ServerConnection.Key.make(session.server.id), session.id)
|
||||
const sideOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "side")
|
||||
const dockOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "dock")
|
||||
const setDock = (session: SessionRef, opened: boolean) => tabs.setPane(shellTab(session), "dock", opened)
|
||||
// A token per session whose side region is open, new each time the region opens.
|
||||
const sideVisits = createMemo<ReadonlyMap<string, object>>(
|
||||
(previous) =>
|
||||
new Map(
|
||||
sessions().flatMap((session) =>
|
||||
sideOpened(session) ? [[session.key, previous.get(session.key) ?? {}] as const] : [],
|
||||
),
|
||||
),
|
||||
new Map(),
|
||||
)
|
||||
// The panel whose toggle opened a side region, by the region's token. However the region closes, it reopens with
|
||||
// a new token, so a region reopened any other way belongs to the user.
|
||||
const openedFor = new WeakMap<object, string>()
|
||||
|
||||
// Keys are `${extension}:${tab id}`; the extension's panel decides the region.
|
||||
const provider = (key: string) => {
|
||||
const extension = key.slice(0, key.indexOf(":"))
|
||||
const matches = host.items(Panel).filter((item) => item.extension === extension)
|
||||
return matches.find((item) => item.value.region === "side") ?? matches[0]
|
||||
}
|
||||
const mountedView = (session: SessionRef) => {
|
||||
const view = current()
|
||||
return view?.key === session.key ? view : undefined
|
||||
}
|
||||
|
||||
// Counts routing visits: each change of the routed session, including to none (e.g. Home), starts the next one.
|
||||
const visit = createMemo(on(routed, (_key, _previous, count: number = 0) => count + 1))
|
||||
// The narrow-screen view belongs to the routed, mounted session for one visit, and reads as the conversation once
|
||||
// another visit starts. A view selected for a session that is not routed (e.g. a file link that opens Files on
|
||||
// another session) belongs to the next visit, which the navigation that follows starts. The dock's view follows
|
||||
// the dock's own per-session state instead.
|
||||
const [mobile, setMobile] = createStore({ session: undefined as string | undefined, view: "session", visit: 0 })
|
||||
const mobileView = createMemo(() =>
|
||||
mobile.session === current()?.key && mobile.visit === visit() ? mobile.view : "session",
|
||||
)
|
||||
const selectMobile = (session: SessionRef, view: string) =>
|
||||
setMobile({ session: session.key, view, visit: session.key === routed() ? visit() : visit() + 1 })
|
||||
|
||||
// The side tabs a mounted session lists right now, plus `adding` as if it were stored; unmounted sessions have none.
|
||||
const listed = (session: SessionRef, value: string, adding?: string) => {
|
||||
const view = mountedView(session)
|
||||
if (!view) return []
|
||||
const all = layout.panel.state(value).all
|
||||
const stored = adding && !all.includes(adding) ? [...all, adding] : all
|
||||
return untrack(() =>
|
||||
host
|
||||
.items(Panel)
|
||||
.filter((item) => item.value.region === "side")
|
||||
.flatMap((item) => {
|
||||
const prefix = `${item.extension}:`
|
||||
const open = stored.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
|
||||
return item.value.list(view, open).map((tab) => ({ key: `${prefix}${tab.id}`, tab }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const open = (key: string, session: SessionRef, options?: Parameters<Layout["open"]>[2]) => {
|
||||
const item = provider(key)
|
||||
if (item?.value.region === "dock") return setDock(session, true)
|
||||
const value = stateKey(session)
|
||||
if (!value) return
|
||||
// focus: false adds the tab quietly: no selection, no region change, no preview replacement.
|
||||
if (options?.focus === false && !options.preview) return layout.panel.append(value, key)
|
||||
// A select keeps the narrow-screen view and dock, as a background open does, and opens the side region too.
|
||||
if (options?.select)
|
||||
return batch(() => {
|
||||
tabs.setPane(shellTab(session), "side", true)
|
||||
layout.panel.append(value, key)
|
||||
layout.panel.focus(value, key)
|
||||
})
|
||||
// Lists the opened tab too, so its own fields apply before it is stored.
|
||||
const known = listed(session, value, key)
|
||||
const launchers = new Set(known.flatMap((entry) => (entry.tab.kind === "launcher" ? [entry.key] : [])))
|
||||
const first = known.some((entry) => entry.key === key && entry.tab.first)
|
||||
batch(() => {
|
||||
if (narrow() && !options?.background) {
|
||||
setDock(session, false)
|
||||
if (item?.value.mobile) selectMobile(session, `${item.extension}:${item.value.id}`)
|
||||
// A tab its panel does not list, or a launcher, stays unstored: the open only selects the panel's view.
|
||||
if (mountedView(session) && !known.some((entry) => entry.key === key && entry.tab.kind !== "launcher")) return
|
||||
}
|
||||
// A background open keeps the narrow-screen view, but its tab still shows once the window is wide.
|
||||
if (!narrow() || options?.background) tabs.setPane(shellTab(session), "side", true)
|
||||
// Pinned tabs are listed without being stored; opening one only selects it.
|
||||
if (known.some((entry) => entry.key === key && entry.tab.kind === "pinned")) return layout.panel.focus(value, key)
|
||||
if (options?.preview) return layout.panel.preview(value, key, launchers)
|
||||
layout.panel.open(value, key, launchers, first)
|
||||
})
|
||||
}
|
||||
|
||||
const close = (key: string, session: SessionRef) => {
|
||||
const item = provider(key)
|
||||
if (item?.value.region === "dock") return setDock(session, false)
|
||||
const value = stateKey(session)
|
||||
if (!value) return
|
||||
const tab = listed(session, value).find((entry) => entry.key === key)?.tab
|
||||
layout.panel.close(value, key)
|
||||
const view = mountedView(session)
|
||||
if (view && tab) item?.value.close?.(tab, view)
|
||||
}
|
||||
|
||||
// The routed session's side region, which knows the fallback selection the stored state lacks.
|
||||
const [region, setRegion] = createSignal<Region>()
|
||||
const opened = createMemo(
|
||||
() => Array.from(new Set((region()?.entries() ?? []).flatMap((entry) => entry.tab.file ?? []))),
|
||||
[],
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const state = (key: string, session: SessionRef): PanelState => {
|
||||
if (provider(key)?.value.region === "dock") return dockOpened(session) ? "visible" : "closed"
|
||||
const value = stateKey(session)
|
||||
if (!value) return "closed"
|
||||
const panel = layout.panel.state(value)
|
||||
const active = mountedView(session) ? (region()?.active() ?? panel.active) : panel.active
|
||||
if (active !== key) return panel.all.includes(key) ? "open" : "closed"
|
||||
return sideOpened(session) ? "visible" : "active"
|
||||
}
|
||||
|
||||
// Open-project requests wait until their server is listed (e.g. an SSH server that just connected).
|
||||
const servers = useServers()
|
||||
const picker = useDirectoryPicker()
|
||||
const [projects, setProjects] = createSignal<readonly { server: string; title: string }[]>([])
|
||||
createEffect(() => {
|
||||
const pending = projects()
|
||||
const ready = pending.flatMap((request) => {
|
||||
const server = servers.list.find((conn) => ServerConnection.key(conn) === request.server)
|
||||
return server ? [{ request, server }] : []
|
||||
})
|
||||
if (ready.length === 0) return
|
||||
setProjects(pending.filter((request) => !ready.some((item) => item.request === request)))
|
||||
untrack(() =>
|
||||
ready.forEach(({ request, server }) =>
|
||||
picker({
|
||||
server,
|
||||
title: request.title,
|
||||
onSelect: (value) => {
|
||||
const directory = Array.isArray(value) ? value[0] : value
|
||||
if (!directory) return
|
||||
const key = ServerConnection.key(server)
|
||||
servers.projects.forServer(key).open(directory)
|
||||
void tabs.newDraft({ server: key, directory })
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const detach = services.attach({
|
||||
sessions,
|
||||
current,
|
||||
scope,
|
||||
scoped: layout.sessionState.track,
|
||||
project: (server, title) => setProjects((pending) => [...pending, { server, title }]),
|
||||
font: () => terminalFontFamily(settings.appearance.terminalFont()),
|
||||
routing: useIsRouting(),
|
||||
path: () => `${location.pathname}${location.search}`,
|
||||
keybind: command.keybindParts,
|
||||
matches: command.matches,
|
||||
servers: () => global.servers.list().map(ServerConnection.key),
|
||||
preferences: {
|
||||
releaseNotes: settings.general.releaseNotes,
|
||||
setReleaseNotes: settings.general.setReleaseNotes,
|
||||
mobileDiffWrap: settings.general.mobileDiffWrap,
|
||||
},
|
||||
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
|
||||
layout: {
|
||||
ready: layout.ready,
|
||||
open,
|
||||
close,
|
||||
toggle(key, session) {
|
||||
if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session))
|
||||
const value = stateKey(session)
|
||||
if (!value) return
|
||||
const region = sideVisits().get(session.key)
|
||||
if (state(key, session) === "visible") {
|
||||
batch(() => {
|
||||
close(key, session)
|
||||
// Closing the last panel the region was opened for also closes the region.
|
||||
if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0)
|
||||
tabs.setPane(shellTab(session), "side", false)
|
||||
})
|
||||
return
|
||||
}
|
||||
// A panel opened into an open region makes the region the user's.
|
||||
if (region) openedFor.delete(region)
|
||||
open(key, session)
|
||||
const opened = sideVisits().get(session.key)
|
||||
if (!region && opened) openedFor.set(opened, key)
|
||||
},
|
||||
state,
|
||||
stored(extension, session) {
|
||||
const value = stateKey(session)
|
||||
if (!value) return []
|
||||
const prefix = `${extension}:`
|
||||
return layout.panel
|
||||
.state(value)
|
||||
.all.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
|
||||
},
|
||||
side: {
|
||||
opened: sideOpened,
|
||||
toggle: (session) => tabs.setPane(shellTab(session), "side", !sideOpened(session)),
|
||||
},
|
||||
dock: {
|
||||
opened: dockOpened,
|
||||
placement: settings.general.terminalPlacement,
|
||||
},
|
||||
scroll: {
|
||||
get(session, key) {
|
||||
const value = stateKey(session)
|
||||
return value ? layout.panel.scroll(value, key) : undefined
|
||||
},
|
||||
set(session, key, next) {
|
||||
const value = stateKey(session)
|
||||
if (value) layout.panel.setScroll(value, key, next)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
onCleanup(detach)
|
||||
|
||||
return {
|
||||
/** The routed, mounted session view. */
|
||||
current,
|
||||
region(value: Region) {
|
||||
setRegion(() => value)
|
||||
return () => {
|
||||
if (region() === value) setRegion(undefined)
|
||||
}
|
||||
},
|
||||
/** Workspace files the routed session's side tabs show, in strip order, and the selected one. */
|
||||
files: {
|
||||
opened,
|
||||
active: () => region()?.selected()?.tab.file,
|
||||
},
|
||||
mobile: {
|
||||
current: mobileView,
|
||||
select(view: string) {
|
||||
const session = current()
|
||||
if (session) selectMobile(session, view)
|
||||
},
|
||||
},
|
||||
mount(key: string, view: SessionView) {
|
||||
views.set(key, view)
|
||||
setMounted("revision", (value) => value + 1)
|
||||
return () => {
|
||||
if (views.get(key) !== view) return
|
||||
views.delete(key)
|
||||
setMounted("revision", (value) => value + 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function requireAttached(value: Attached | undefined) {
|
||||
if (!value) throw new Error("The app interface is not mounted")
|
||||
return value
|
||||
}
|
||||
|
||||
/** Imports a session's entry from an app key that holds every session's state under one field. */
|
||||
function sessionCopy(from: StorageFrom, session: SessionStateKey) {
|
||||
if (typeof from !== "object" || !from.sessions) return
|
||||
const field = from.sessions
|
||||
return {
|
||||
key: from.key,
|
||||
storage: Persist.global(from.key).storage,
|
||||
pick: (value: unknown) => {
|
||||
const sessions = Predicate.isObject(value) ? value[field] : undefined
|
||||
return from.pick(Predicate.isObject(sessions) ? sessions[session] : undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
+2
-11
@@ -14,8 +14,7 @@ export function GuiExtensionsSettings() {
|
||||
const language = useLanguage()
|
||||
const host = useExtensionHost()
|
||||
const bridge = usePlatform().extensions
|
||||
const [installed, setInstalled] = createStore<{ list: Installed[] }>({ list: [] })
|
||||
|
||||
const [installed, setInstalled] = createStore({ list: [] as Installed[] })
|
||||
if (bridge) {
|
||||
void bridge.manager.list().then((list) => setInstalled("list", reconcile([...list])))
|
||||
onCleanup(
|
||||
@@ -24,23 +23,15 @@ export function GuiExtensionsSettings() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const enabled = (id: string) => installed.list.find((item) => item.id === id)?.enabled ?? true
|
||||
|
||||
const status = (id: string) => {
|
||||
const value = host.state.status[id] ?? "loading"
|
||||
|
||||
if (value === "active") return language.t("settings.guiExtensions.status.active")
|
||||
|
||||
if (value === "failed") return language.t("settings.guiExtensions.status.failed")
|
||||
|
||||
if (value === "disabled") return language.t("settings.guiExtensions.status.disabled")
|
||||
|
||||
return language.t("settings.guiExtensions.status.loading")
|
||||
}
|
||||
|
||||
const toggle = (id: string, next: boolean) => void (next ? bridge?.manager.enable(id) : bridge?.manager.disable(id))
|
||||
|
||||
// Main entries reload through the manager; the renderer entry reloads here.
|
||||
const reload = (id: string) => {
|
||||
host.reload(id)
|
||||
@@ -69,7 +60,7 @@ export function GuiExtensionsSettings() {
|
||||
description={
|
||||
<>
|
||||
{status(definition.id)}
|
||||
<Show when={host.state.failures[definition.id]?.error}>
|
||||
<Show when={host.state.errors[definition.id]}>
|
||||
{(error) => <pre class="mt-1 whitespace-pre-wrap text-text-danger-base">{error()}</pre>}
|
||||
</Show>
|
||||
</>
|
||||
+5
-11
@@ -3,10 +3,9 @@ import { Tabs } from "@opencode/ui/tabs"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { Contribution } from "./render"
|
||||
|
||||
/** One settings panel per extension page; the tab value is the SettingsPage id. */
|
||||
export function ExtensionSettingsPages() {
|
||||
/** One settings panel per extension page; the tab value is the Setting id. */
|
||||
export function ExtensionSettingPages() {
|
||||
const surface = useSettingsSurface()
|
||||
|
||||
return (
|
||||
<For each={surface.extensions.pages()}>
|
||||
{(item) => (
|
||||
@@ -16,7 +15,6 @@ export function ExtensionSettingsPages() {
|
||||
item.value.render({
|
||||
get target() {
|
||||
const view = surface.view()
|
||||
|
||||
return view.tab === item.value.id ? view.target : undefined
|
||||
},
|
||||
})
|
||||
@@ -28,15 +26,11 @@ export function ExtensionSettingsPages() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension sections on a host settings page, in contribution order; with `section`, the extension rows placed in
|
||||
* that host section, rendered where the host lists them.
|
||||
*/
|
||||
export function ExtensionSettingsSections(props: { page: "general" | "servers"; section?: "general" }) {
|
||||
/** Extension sections on a host settings page, in contribution order. */
|
||||
export function ExtensionSettingSections(props: { page: "general" | "servers" }) {
|
||||
const surface = useSettingsSurface()
|
||||
|
||||
return (
|
||||
<For each={surface.extensions.sections(props.page, props.section)}>
|
||||
<For each={surface.extensions.sections(props.page)}>
|
||||
{(item) => (
|
||||
<Contribution extension={item.extension}>
|
||||
{() =>
|
||||
+7
-14
@@ -1,38 +1,31 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { SettingsPage } from "@opencode/gui-extensions/sdk"
|
||||
import { Setting } from "@opencode/gui-extensions/sdk"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useExtensionHost } from "./host"
|
||||
|
||||
export type SettingsPageItem = ReturnType<ReturnType<typeof createSettingsPages>["items"]>[number]
|
||||
export type SettingItem = ReturnType<ReturnType<typeof createExtensionSettings>["items"]>[number]
|
||||
|
||||
/** Extension settings pages that apply to this platform and viewport. */
|
||||
export function createSettingsPages() {
|
||||
/** Extension settings that apply to this platform and viewport. */
|
||||
export function createExtensionSettings() {
|
||||
const host = useExtensionHost()
|
||||
const platform = usePlatform()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const items = createMemo(() =>
|
||||
host.items(SettingsPage).filter((item) => {
|
||||
host.items(Setting).filter((item) => {
|
||||
if (item.value.available === "desktop") return platform.platform === "desktop"
|
||||
|
||||
if (item.value.available === "mobile") return mobile()
|
||||
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
const pages = createMemo(() => items().filter((item) => !item.value.page))
|
||||
const tabs = createMemo<ReadonlySet<string>>(() => new Set(pages().map((item) => item.value.id)))
|
||||
|
||||
return {
|
||||
items,
|
||||
/** Settings pages without a host page, in contribution order. */
|
||||
/** Settings without a host page, in contribution order. */
|
||||
pages,
|
||||
/** Their tab values. */
|
||||
tabs,
|
||||
/** Sections on a host page, or with `section`, the rows placed in that host section. */
|
||||
sections: (page: "general" | "servers", section?: "general") =>
|
||||
items().filter((item) => item.value.page === page && item.value.section === section),
|
||||
sections: (page: "general" | "servers") => items().filter((item) => item.value.page === page),
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,10 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
|
||||
import {
|
||||
MenuItem,
|
||||
type MountedSession,
|
||||
Menu as MenuPoint,
|
||||
type Menu as MenuItem,
|
||||
type PanelSidebar,
|
||||
type SessionPanelMenuItem,
|
||||
type SessionView,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -36,7 +36,7 @@ const FILE_TREE_WIDTH_MIN = 240
|
||||
|
||||
/** The side region: the tab strip with its "+" menu, the selected panel, and the inner sidebar. */
|
||||
export function SideRegion(props: {
|
||||
view: MountedSession
|
||||
view: SessionView
|
||||
region: Region
|
||||
sidebar: PanelSidebar
|
||||
fileTree: boolean
|
||||
@@ -56,21 +56,16 @@ export function SideRegion(props: {
|
||||
const open = createMemo(() => tabsOpen() || fileOpen())
|
||||
const visible = createMemo(() => tabsVisible() || fileOpen())
|
||||
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
|
||||
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!visible()) return "0px"
|
||||
|
||||
if (tabsVisible()) return "auto"
|
||||
|
||||
return `${fileTreeWidth()}px`
|
||||
})
|
||||
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
|
||||
const menu = createMemo(() =>
|
||||
host
|
||||
.list(MenuItem)
|
||||
.flatMap((item) => (item.menu === "session.panel" ? [item] : []))
|
||||
.list(MenuPoint)
|
||||
.filter((item) => item.menu === "session.panel")
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
)
|
||||
|
||||
@@ -125,7 +120,6 @@ export function SideRegion(props: {
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
|
||||
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
|
||||
tabs().move(source.id.toString(), source.index)
|
||||
}}
|
||||
@@ -173,7 +167,6 @@ export function SideRegion(props: {
|
||||
value={key}
|
||||
extension={entry().extension}
|
||||
tab={entry().tab}
|
||||
session={props.view}
|
||||
index={tabs().all().indexOf(key)}
|
||||
active={props.region.active() === key}
|
||||
preview={tabs().preview() === key}
|
||||
@@ -200,14 +193,7 @@ export function SideRegion(props: {
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExtensionSlot
|
||||
at="session.panel.end"
|
||||
input={{
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ExtensionSlot at="session.panel.end" input={{ session: props.view }} />
|
||||
<Show when={tabsVisible()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
@@ -258,14 +244,7 @@ export function SideRegion(props: {
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": tabsOpen() }}
|
||||
>
|
||||
<ExtensionSlot
|
||||
at="session.panel.sidebar"
|
||||
input={{
|
||||
get session() {
|
||||
return props.view
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ExtensionSlot at="session.panel.sidebar" input={{ session: props.view }} />
|
||||
</div>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
@@ -289,10 +268,9 @@ export function SideRegion(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function AddButton(props: { item: SessionPanelMenuItem }): JSX.Element {
|
||||
function AddButton(props: { item: MenuItem }): JSX.Element {
|
||||
const command = useCommand()
|
||||
const keybind = createMemo(() => (props.item.keybind ? command.keybindParts(props.item.keybind) : []))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -310,17 +288,16 @@ function AddButton(props: { item: SessionPanelMenuItem }): JSX.Element {
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => props.item.run()}
|
||||
onClick={() => props.item.run("")}
|
||||
aria-label={props.item.title}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AddMenu(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element {
|
||||
function AddMenu(props: { items: readonly MenuItem[] }): JSX.Element {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
|
||||
return (
|
||||
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
|
||||
<Menu appearance="standard" modal={false} placement="bottom-start" gutter={4}>
|
||||
@@ -339,11 +316,10 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element
|
||||
<For each={props.items}>
|
||||
{(item) => {
|
||||
const keybind = createMemo(() => (item.keybind ? command.keybindParts(item.keybind) : []))
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
class="!gap-6"
|
||||
onSelect={() => item.run()}
|
||||
onSelect={() => item.run("")}
|
||||
shortcut={
|
||||
<Show when={keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
|
||||
+10
-16
@@ -1,40 +1,35 @@
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TitlebarItem } from "@opencode/gui-extensions/sdk"
|
||||
import { Status } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "./host"
|
||||
|
||||
/** TitlebarItem contributions placed in the titlebar (the default placement). */
|
||||
export function useTitlebarItems() {
|
||||
/** Status items placed in the titlebar (the default placement). */
|
||||
export function useTitlebarStatus() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
const items = createMemo(() =>
|
||||
host.list(TitlebarItem).filter((item) => (item.placement ?? "titlebar") === "titlebar"),
|
||||
)
|
||||
|
||||
const items = createMemo(() => host.list(Status).filter((item) => (item.placement ?? "titlebar") === "titlebar"))
|
||||
// Keyed by id so a pill keeps its element (and hover state) while its item changes.
|
||||
const ids = createMemo(() => items().map((item) => item.id), undefined, {
|
||||
equals: (a, b) => a.length === b.length && a.every((id, index) => id === b[index]),
|
||||
})
|
||||
|
||||
return {
|
||||
ids,
|
||||
item: (id: string) => items().find((item) => item.id === id),
|
||||
}
|
||||
}
|
||||
|
||||
export function TitlebarItems(props: { items: ReturnType<typeof useTitlebarItems>; vertical?: boolean }) {
|
||||
export function TitlebarStatusItems(props: { status: ReturnType<typeof useTitlebarStatus>; vertical?: boolean }) {
|
||||
return (
|
||||
<For each={props.items.ids()}>
|
||||
<For each={props.status.ids()}>
|
||||
{(id) => (
|
||||
<Show when={props.items.item(id)}>
|
||||
{(item) => <TitlebarItemButton item={item()} vertical={props.vertical} />}
|
||||
<Show when={props.status.item(id)}>
|
||||
{(item) => <TitlebarStatusButton item={item()} vertical={props.vertical} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
function TitlebarItemButton(props: { item: TitlebarItem; vertical?: boolean }) {
|
||||
function TitlebarStatusButton(props: { item: Status; vertical?: boolean }) {
|
||||
const label = () => (
|
||||
<span
|
||||
class="shrink-0 text-[11px] leading-4 text-v2-text-text-accent [font-weight:530] opacity-0 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"
|
||||
@@ -46,7 +41,6 @@ function TitlebarItemButton(props: { item: TitlebarItem; vertical?: boolean }) {
|
||||
{props.item.label}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="titlebar-update"
|
||||
@@ -60,7 +54,7 @@ function TitlebarItemButton(props: { item: TitlebarItem; vertical?: boolean }) {
|
||||
type="button"
|
||||
class="absolute top-0 z-10 flex h-full w-full items-center overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[background-color] duration-150 ease-out group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none [app-region:no-drag]"
|
||||
classList={{ "start-0 justify-start": props.vertical, "end-0 justify-end": !props.vertical }}
|
||||
onClick={() => props.item.run?.()}
|
||||
onClick={() => props.item.run()}
|
||||
disabled={!!props.item.busy}
|
||||
aria-busy={!!props.item.busy}
|
||||
aria-label={props.item.title ?? props.item.label}
|
||||
@@ -1,245 +0,0 @@
|
||||
import {
|
||||
batch,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createRoot,
|
||||
createSignal,
|
||||
on,
|
||||
untrack,
|
||||
type Accessor,
|
||||
type Owner,
|
||||
} from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import { Predicate } from "effect"
|
||||
import type { Persisted, SessionRef, StoreFrom } from "@opencode/gui-extensions/sdk"
|
||||
import { Persist, type persisted } from "@/runtime/persistence/storage"
|
||||
import type { SessionStateKey } from "@/runtime/server/scope"
|
||||
|
||||
type Mutation<T> = Parameters<Persisted<T>["update"]>[0]
|
||||
|
||||
/** One older key persistence imports a store's value from. */
|
||||
type CopyFrom = NonNullable<Exclude<Parameters<typeof persisted>[0], string>["copyFrom"]>[number]
|
||||
|
||||
const loads = new WeakMap<object, Promise<void>>()
|
||||
|
||||
/** The storage key of an extension's store: the extension id is its namespace. */
|
||||
export function storeName(extension: string, key: string) {
|
||||
return `extension.${extension}.${key}`
|
||||
}
|
||||
|
||||
/** Where `Storage.store` keeps an extension's app-wide store, with the older keys it imports. */
|
||||
export function globalStoreTarget(extension: string, key: string, from: StoreFrom | readonly StoreFrom[] | undefined) {
|
||||
return { ...Persist.global(storeName(extension, key)), copyFrom: storeImports(from) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A store's `from` in the form persistence takes, in order. For a session store, an app key holding every session's
|
||||
* state under one field imports only that session's entry.
|
||||
*/
|
||||
export function storeImports(
|
||||
from: StoreFrom | readonly StoreFrom[] | undefined,
|
||||
session?: SessionStateKey,
|
||||
): CopyFrom[] {
|
||||
return [from ?? []].flat().map((item) => (session && sessionCopy(item, session)) || copySpec(item))
|
||||
}
|
||||
|
||||
function copySpec(from: StoreFrom): CopyFrom {
|
||||
return Predicate.isString(from) ? { key: from } : from
|
||||
}
|
||||
|
||||
function sessionCopy(from: StoreFrom, session: SessionStateKey): CopyFrom | undefined {
|
||||
if (Predicate.isString(from) || !from.sessions) return
|
||||
|
||||
const field = from.sessions
|
||||
|
||||
return {
|
||||
key: from.key,
|
||||
storage: Persist.global(from.key).storage,
|
||||
pick: (value) => {
|
||||
const sessions = Predicate.isObject(value) ? value[field] : undefined
|
||||
|
||||
return from.pick(Predicate.isObject(sessions) ? sessions[session] : undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What `Storage.store` returns: a `Persisted` whose `update` waits until the stored value has loaded, then applies in
|
||||
* call order.
|
||||
*/
|
||||
export function persistedHandle<T extends object>(input: {
|
||||
readonly store: T
|
||||
/** The persisted store's setter. */
|
||||
readonly set: SetStoreFunction<T>
|
||||
/** The storage read; undefined when storage answered synchronously. */
|
||||
readonly init: Promise<unknown> | undefined
|
||||
}) {
|
||||
const [loaded, setLoaded] = createSignal(!input.init)
|
||||
const queue: Mutation<T>[] = []
|
||||
|
||||
// A mutation edits the draft in place, or returns the next value, which replaces the stored one entirely.
|
||||
const apply = (mutation: Mutation<T>) =>
|
||||
batch(() => {
|
||||
const replaced: T[] = []
|
||||
|
||||
input.set(
|
||||
produce((draft) => {
|
||||
const next = mutation(draft)
|
||||
|
||||
if (next) replaced.push(next)
|
||||
}),
|
||||
)
|
||||
replaced.forEach((next) => input.set(reconcile(next)))
|
||||
})
|
||||
|
||||
// Registered after the store's own hydration on the same read, so queued changes apply over the stored value.
|
||||
const load = input.init?.then(() =>
|
||||
batch(() => {
|
||||
queue.splice(0).forEach(apply)
|
||||
setLoaded(true)
|
||||
}),
|
||||
)
|
||||
|
||||
void load?.catch(() => undefined)
|
||||
|
||||
const handle: Persisted<T> = {
|
||||
get value() {
|
||||
return loaded() ? input.store : undefined
|
||||
},
|
||||
ready: loaded,
|
||||
update(mutation) {
|
||||
if (untrack(loaded)) return apply(mutation)
|
||||
|
||||
queue.push(mutation)
|
||||
},
|
||||
}
|
||||
|
||||
if (load) loads.set(handle, load)
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Settles once a handle from `persistedHandle` has loaded; rejects when its storage read failed. */
|
||||
export function whenLoaded<T>(handle: Persisted<T>) {
|
||||
return loads.get(handle) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Persisted` over a store that opens later: `value` is undefined and `ready()` false until `store` returns one, and
|
||||
* changes made before then wait and apply in order. Call it inside an owner, which ends the hand-over.
|
||||
*/
|
||||
export function deferredHandle<T>(store: Accessor<Persisted<T> | undefined>): Persisted<T> {
|
||||
const queue: Mutation<T>[] = []
|
||||
|
||||
// Hands changes made before the store opened to it, which applies them once it has loaded.
|
||||
createRenderEffect(() => {
|
||||
const current = store()
|
||||
|
||||
if (current && queue.length > 0) untrack(() => queue.splice(0).forEach((mutation) => current.update(mutation)))
|
||||
})
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return store()?.value
|
||||
},
|
||||
ready: () => store()?.ready() ?? false,
|
||||
update(mutation) {
|
||||
const current = untrack(store)
|
||||
|
||||
if (current) return current.update(mutation)
|
||||
queue.push(mutation)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A store of one session, which needs the session's location: it opens once the location is known, and again in a
|
||||
* new directory. The session is one session's ref or `MountedSession`, so it never reads another session's location.
|
||||
*/
|
||||
export function locatedHandle<T>(session: SessionRef, open: () => Persisted<T>) {
|
||||
const directory = createMemo(() => session.location?.directory)
|
||||
|
||||
// A new directory opens the store again; the store from the old one disposes with the previous run.
|
||||
return deferredHandle(createMemo(on(directory, (value) => (value === undefined ? undefined : open()))))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared session store: one handle per session, opened through `locatedHandle`. Call it under an owner: an effect
|
||||
* drops the store of a session whose tab closed.
|
||||
*/
|
||||
export function createSessionStore<T extends object>(input: {
|
||||
readonly open: (session: SessionRef) => Persisted<T>
|
||||
readonly owner: Owner | null
|
||||
/**
|
||||
* The host's sessions. `list` holds the refs of open tabs, which follow their servers' live controllers; the ref a
|
||||
* store first opens with, such as a screen's `MountedSession`, reads its own screen's controller. `current` is the
|
||||
* routed session. Reactive.
|
||||
*/
|
||||
readonly sessions: { list(): readonly SessionRef[]; current(): SessionRef | undefined }
|
||||
}) {
|
||||
const entries = new Map<string, { readonly handle: Persisted<T>; readonly dispose: () => void }>()
|
||||
// Keys a tab has listed while their store was open; only these can close.
|
||||
const listed = new Set<string>()
|
||||
|
||||
// Drops the stores of sessions whose tabs closed. The routed session keeps its store, and so does one no tab has
|
||||
// listed yet: a deep link or a new session routes and preloads before the titlebar adds its tab.
|
||||
createRenderEffect(() => {
|
||||
const keys = new Set(input.sessions.list().map((session) => session.key))
|
||||
const routed = input.sessions.current()?.key
|
||||
|
||||
untrack(() =>
|
||||
entries.forEach((entry, key) => {
|
||||
if (keys.has(key)) return void listed.add(key)
|
||||
|
||||
if (key === routed || !listed.has(key)) return
|
||||
entry.dispose()
|
||||
entries.delete(key)
|
||||
listed.delete(key)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const create = (session: SessionRef) => {
|
||||
// The first ref stands in until the host lists the key, and again if its tab closes before the store drops.
|
||||
const current = () => input.sessions.list().find((item) => item.key === session.key) ?? session
|
||||
|
||||
const ref: SessionRef = {
|
||||
key: session.key,
|
||||
id: session.id,
|
||||
get tab() {
|
||||
return current().tab
|
||||
},
|
||||
get server() {
|
||||
return current().server
|
||||
},
|
||||
get pending() {
|
||||
return current().pending
|
||||
},
|
||||
get location() {
|
||||
return current().location
|
||||
},
|
||||
}
|
||||
|
||||
return createRoot((dispose) => ({ handle: locatedHandle(ref, () => input.open(ref)), dispose }), input.owner)
|
||||
}
|
||||
|
||||
return {
|
||||
get(session: SessionRef) {
|
||||
const existing = entries.get(session.key)
|
||||
|
||||
if (existing) return existing.handle
|
||||
const created = create(session)
|
||||
entries.set(session.key, created)
|
||||
|
||||
// Listed already, it closes with its tab.
|
||||
if (untrack(input.sessions.list).some((item) => item.key === session.key)) listed.add(session.key)
|
||||
|
||||
return created.handle
|
||||
},
|
||||
dispose() {
|
||||
entries.forEach((entry) => entry.dispose())
|
||||
entries.clear()
|
||||
listed.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
+21
-53
@@ -2,7 +2,7 @@ import { createEffect, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import type { EmbedProps, Embeds } from "@opencode/gui-extensions/sdk"
|
||||
import type { SurfaceProps, Surfaces } from "@opencode/gui-extensions/sdk"
|
||||
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
const geometry =
|
||||
@@ -14,65 +14,56 @@ type Input = {
|
||||
readonly dialog: () => boolean
|
||||
}
|
||||
|
||||
/** Main-process embeds laid out over the DOM box an extension renders for them. */
|
||||
export function createEmbeds(input: Input): Embeds {
|
||||
/** Native main-process surfaces laid out over the DOM box an extension renders for them. */
|
||||
export function createSurfaces(input: Input): Surfaces {
|
||||
return {
|
||||
View: (props) => {
|
||||
const bridge = input.bridge
|
||||
|
||||
// Without the desktop bridge there is no embed; the box still renders its children.
|
||||
// Without the desktop bridge there is no surface; the box still renders its children.
|
||||
if (!bridge) return <div class={props.class}>{props.children}</div>
|
||||
|
||||
return <EmbedView {...props} input={input} bridge={bridge} />
|
||||
return <SurfaceView {...props} input={input} bridge={bridge} />
|
||||
},
|
||||
capture: (id) => input.bridge?.capture(id) ?? Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
const [store, setStore] = createStore<{ visible: boolean; snapshot: { id: string; url: string } | undefined }>({
|
||||
function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
|
||||
const [store, setStore] = createStore({
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
// A still of the embed shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined,
|
||||
// A still of the surface shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined as { id: string; url: string } | undefined,
|
||||
})
|
||||
|
||||
let element: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
let capturing: string | undefined
|
||||
let release: ReturnType<typeof setTimeout> | undefined
|
||||
// The embed the last layout went to; it hides when another one takes the box.
|
||||
// The surface the last layout went to; it hides when another one takes the box.
|
||||
let placed: string | undefined
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
|
||||
const hide = () => {
|
||||
if (placed) props.bridge.embed(placed)
|
||||
if (placed) props.bridge.surface(placed)
|
||||
placed = undefined
|
||||
}
|
||||
|
||||
// The native embed always paints above the DOM, so hide it while a floating
|
||||
// The native surface always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
|
||||
const replaceSnapshot = (next?: { id: string; url: string }) => {
|
||||
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
|
||||
setStore("snapshot", next)
|
||||
}
|
||||
|
||||
// Keep the embed on screen as a still under the floating content. The native view
|
||||
// Keep the surface on screen as a still under the floating content. The native view
|
||||
// stays visible until the still has decoded, so the box never flashes blank.
|
||||
const freeze = (id: string) => {
|
||||
clearTimeout(release)
|
||||
release = undefined
|
||||
|
||||
if (store.snapshot?.id === id || capturing === id) return
|
||||
capturing = id
|
||||
void props.bridge
|
||||
@@ -80,29 +71,23 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
.catch(() => undefined)
|
||||
.then(async (data) => {
|
||||
const url = data ? URL.createObjectURL(new Blob([new Uint8Array(data)], { type: "image/jpeg" })) : ""
|
||||
|
||||
if (url) {
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode().catch(() => undefined)
|
||||
}
|
||||
|
||||
if (capturing !== id) {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
capturing = undefined
|
||||
// A failed capture still hides the embed; the box shows its background as before.
|
||||
// A failed capture still hides the surface; the box shows its background as before.
|
||||
replaceSnapshot({ id, url })
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
|
||||
const thaw = () => {
|
||||
capturing = undefined
|
||||
|
||||
if (!store.snapshot || release !== undefined) return
|
||||
// Keep the still under the native view until the view has painted again.
|
||||
release = setTimeout(() => {
|
||||
@@ -110,11 +95,9 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
replaceSnapshot()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
if (!element) return
|
||||
const id = props.id
|
||||
|
||||
if (!id) return hide()
|
||||
const rect = element.getBoundingClientRect()
|
||||
const zoom = props.input.zoom()
|
||||
@@ -124,22 +107,16 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const shown = props.visible && store.visible && !props.input.dialog()
|
||||
const cover = !!props.frozen || covered(rect)
|
||||
|
||||
if (shown && cover) freeze(id)
|
||||
|
||||
if (!cover) thaw()
|
||||
const visible = shown && !(cover && store.snapshot?.id === id)
|
||||
|
||||
// The cutout exposes the app backdrop outside the rounded card, not the embed inside it.
|
||||
// The cutout exposes the app backdrop outside the rounded card, not the surface inside it.
|
||||
const color =
|
||||
props.background ??
|
||||
getComputedStyle(element.closest(".bg-v2-background-bg-deep") ?? document.documentElement).backgroundColor
|
||||
|
||||
const next = `${id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
|
||||
|
||||
if (next === layout) return
|
||||
layout = next
|
||||
|
||||
// Let the browser resolve the color, including custom themes using color formats
|
||||
// that Electron's color parser cannot read.
|
||||
if (paint) {
|
||||
@@ -147,31 +124,23 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
|
||||
if (placed !== id) hide()
|
||||
placed = id
|
||||
|
||||
const box = {
|
||||
props.bridge.surface(id, {
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
...(rgba ? { background: [rgba[0], rgba[1], rgba[2], rgba[3]] as const } : {}),
|
||||
radius: Math.round((props.radius ?? 0) * zoom),
|
||||
}
|
||||
|
||||
props.bridge.embed(id, rgba ? { ...box, background: [rgba[0], rgba[1], rgba[2], rgba[3]] as const } : box)
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
@@ -187,16 +156,15 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
|
||||
],
|
||||
() => {
|
||||
layout = undefined
|
||||
|
||||
// The native views are not clipped by the retained panel's DOM. Hide before the next
|
||||
// animation frame so closing the panel cannot leave the embed above the app.
|
||||
// Native views are not clipped by the retained panel's DOM. Hide before the next
|
||||
// animation frame so closing the panel cannot leave the surface above the app.
|
||||
if (!props.visible || !store.visible || props.input.dialog() || !props.id) return hide()
|
||||
schedule(300)
|
||||
},
|
||||
),
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a region drag.
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => element, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// A layout transition elsewhere, such as the chat column's width while the side pane opens, can
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import type {
|
||||
BackgroundTask,
|
||||
Comments,
|
||||
Composer,
|
||||
Files,
|
||||
LineRange,
|
||||
ServerRef,
|
||||
SessionView,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useExtensionAttachment } from "./services"
|
||||
|
||||
const noTasks: readonly BackgroundTask[] = []
|
||||
|
||||
/** The routed session as extensions see it. One stable object that follows the route. */
|
||||
export function createSessionView(session: SessionModel) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
const composer = useComposerState()
|
||||
const server = useServer()
|
||||
const location = useWorkspaceLocation()
|
||||
const attachment = useExtensionAttachment()
|
||||
// The composer region owns background tasks and is created after the view.
|
||||
const [background, setBackground] = createSignal<Accessor<readonly BackgroundTask[]>>()
|
||||
|
||||
const serverRef: ServerRef = {
|
||||
get id() {
|
||||
return server.key
|
||||
},
|
||||
get name() {
|
||||
return serverName(server.conn) || server.key
|
||||
},
|
||||
get url() {
|
||||
return server.ctx.sdk.url
|
||||
},
|
||||
get password() {
|
||||
return server.conn.http.password
|
||||
},
|
||||
get client() {
|
||||
return server.ctx.sdk.api
|
||||
},
|
||||
get data() {
|
||||
return server.ctx.data
|
||||
},
|
||||
get local() {
|
||||
return server.isLocal
|
||||
},
|
||||
get builtin() {
|
||||
return ServerConnection.builtin(server.conn)
|
||||
},
|
||||
get compatible() {
|
||||
return !server.health?.incompatible
|
||||
},
|
||||
get connected() {
|
||||
return server.ctx.sdk.connection.status() === "connected"
|
||||
},
|
||||
}
|
||||
|
||||
const files: Files = {
|
||||
get root() {
|
||||
return location().directory
|
||||
},
|
||||
ready: file.ready,
|
||||
resolve: file.normalize,
|
||||
absolute: file.absolute,
|
||||
get: file.get,
|
||||
missing: file.notFound,
|
||||
sync: (path, options) => file.load(path, options),
|
||||
search: (query, options) =>
|
||||
options?.kind === "any" ? file.searchFilesAndDirectories(query) : file.searchFiles(query, options),
|
||||
selection: {
|
||||
get: (path) => file.selectedLines(path) as LineRange | null | undefined,
|
||||
set: (path, range) => void file.setSelectedLines(path, range),
|
||||
},
|
||||
scroll: {
|
||||
get: (path) => ({
|
||||
top: file.scrollTop(path) as number | undefined,
|
||||
left: file.scrollLeft(path) as number | undefined,
|
||||
}),
|
||||
set(path, value) {
|
||||
if (value.top !== undefined) file.setScrollTop(path, value.top)
|
||||
if (value.left !== undefined) file.setScrollLeft(path, value.left)
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
list: file.tree.children,
|
||||
state: file.tree.state,
|
||||
sync: (path, options) => (options?.force ? file.tree.refresh(path) : file.tree.list(path)),
|
||||
expand: (path, options) => void file.tree.expand(path, options),
|
||||
collapse: (path) => void file.tree.collapse(path),
|
||||
},
|
||||
}
|
||||
|
||||
const commentFile = (id: string) => comments.all().find((item) => item.id === id)?.file
|
||||
const comment: Comments = {
|
||||
list: (path) => (path ? comments.list(path) : comments.all()),
|
||||
add: comments.add,
|
||||
update(id, text) {
|
||||
const path = commentFile(id)
|
||||
if (path) comments.update(path, id, text)
|
||||
},
|
||||
remove(id) {
|
||||
const path = commentFile(id)
|
||||
if (path) comments.remove(path, id)
|
||||
},
|
||||
focus: { current: comments.focus, set: (value) => void comments.setFocus(value) },
|
||||
active: { current: comments.active, set: (value) => void comments.setActive(value) },
|
||||
}
|
||||
|
||||
const composerRef: Composer = {
|
||||
attach: (part) => composer.context.add(part),
|
||||
update: (id, patch) => composer.context.updateComment(id, patch),
|
||||
detach: (id) => composer.context.removeComment(id),
|
||||
}
|
||||
|
||||
// Only a project opened at this exact directory; a session in a project subfolder has none.
|
||||
const listedProject = createMemo(() => {
|
||||
const directory = pathKey(location().directory)
|
||||
return server.ctx.projects
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
)
|
||||
})
|
||||
|
||||
const view: SessionView = {
|
||||
get key() {
|
||||
return `${server.key}\n${session.identity.sessionID() ?? ""}`
|
||||
},
|
||||
get id() {
|
||||
return session.identity.sessionID() ?? ""
|
||||
},
|
||||
get tab() {
|
||||
return session.layout.tabKey() ?? ""
|
||||
},
|
||||
server: serverRef,
|
||||
get pending() {
|
||||
return server.ctx.data.session.creating(session.identity.sessionID() ?? "")
|
||||
},
|
||||
get location() {
|
||||
return session.data.info()?.location
|
||||
},
|
||||
// Global sync adds the worktrees found on disk, and the user's local name and icon override the server's.
|
||||
// Raw metadata stands in until global sync lists the project.
|
||||
get project() {
|
||||
const info = session.data.info()
|
||||
return (info && server.ctx.projects.detailsForSession(info)) || session.project()
|
||||
},
|
||||
get listedProject() {
|
||||
return listedProject()
|
||||
},
|
||||
get directory() {
|
||||
return session.workspace.directory()
|
||||
},
|
||||
get local() {
|
||||
return !session.workspace.current()
|
||||
},
|
||||
get background() {
|
||||
return background()?.() ?? noTasks
|
||||
},
|
||||
file: files,
|
||||
comment,
|
||||
composer: composerRef,
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!session.identity.sessionID()) return
|
||||
onCleanup(attachment.mount(view.key, view))
|
||||
})
|
||||
|
||||
return { view, bindBackground: (tasks: Accessor<readonly BackgroundTask[]>) => setBackground(() => tasks) }
|
||||
}
|
||||
@@ -295,6 +295,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "{{color}} ቀለም ይምረጡ",
|
||||
"dialog.project.edit.worktree.startup": "የworkspace ማስጀመሪያ ስክሪፕት",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "ለምሳሌ፡. ጥቅል",
|
||||
"dialog.releaseNotes.action.getStarted": "ጀምር",
|
||||
"dialog.releaseNotes.action.next": "ቀጣይ",
|
||||
"dialog.releaseNotes.action.hideFuture": "እነዚህን ወደፊት አታሳይ",
|
||||
"dialog.releaseNotes.media.alt": "የልቀት ቅድመ እይታ",
|
||||
"toast.permissions.autoaccept.on.title": "በራስ-ተቀባይ ፍቃዶች",
|
||||
"toast.permissions.autoaccept.on.description": "የፈቃድ ጥያቄዎች በራስ-ሰር ይጸድቃሉ",
|
||||
"toast.permissions.autoaccept.off.title": "በራስ-መቀበል ፍቃዶችን አቁሟል",
|
||||
@@ -697,6 +701,7 @@ export const dict = {
|
||||
"session.view.select": "የክፍለ ጊዜ እይታ",
|
||||
"session.background.moveRunning": "ወደ ዳራ አንቀሳቅስ",
|
||||
"session.timeline.working": "በመስራት ላይ",
|
||||
"session.review.wrapLines": "መስመሮችን አጠፍ",
|
||||
"session.websearch.title": "የሶስተኛ ወገን ድር ፍለጋ",
|
||||
"session.websearch.description": "ወኪሎች ድሩን ለመፈለግ የሚጠቀሙበትን የፍለጋ አቅራቢ ይምረጡ",
|
||||
"session.websearch.provider": "የፍለጋ አቅራቢ",
|
||||
@@ -754,6 +759,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "ንዑስ ወኪሎች",
|
||||
"settings.timeline.category.notices": "ማሳወቂያዎች",
|
||||
"settings.timeline.category.tools": "ሌሎች መሳሪያዎች",
|
||||
"settings.general.row.mobileDiffWrap.description": "በአግድም ከማሸብለል ይልቅ በሞባይል diff ውስጥ ረጅም መስመሮችን አጠፍ",
|
||||
"provider.connect.error.unsupportedFields": "ይህ የማረጋገጫ ቅጽ የማይደገፉ መስኮችን ይዟል",
|
||||
"settings.about.writtenByNames": "የተጻፈው በ{{names}}",
|
||||
"settings.about.illustratedByNames": "ምስሎች በ{{names}}",
|
||||
|
||||
@@ -577,6 +577,10 @@ export const dict = {
|
||||
"workspace.delete.confirm": 'حذف مساحة العمل "{{name}}"؟',
|
||||
"workspace.delete.button": "حذف مساحة العمل",
|
||||
"common.open": "فتح",
|
||||
"dialog.releaseNotes.action.getStarted": "البدء",
|
||||
"dialog.releaseNotes.action.next": "التالي",
|
||||
"dialog.releaseNotes.action.hideFuture": "عدم إظهارها مستقبلاً",
|
||||
"dialog.releaseNotes.media.alt": "معاينة الإصدار",
|
||||
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
|
||||
"error.server.invalidConfiguration": "تكوين غير صالح",
|
||||
"common.moreCountSuffix": " (+{{count}} إضافي)",
|
||||
@@ -757,6 +761,7 @@ export const dict = {
|
||||
"session.background.subagent.few": "{{count}} وكلاء فرعيين",
|
||||
"session.background.subagent.many": "{{count}} وكيلًا فرعيًا",
|
||||
"session.timeline.working": "جارٍ العمل",
|
||||
"session.review.wrapLines": "التفاف الأسطر",
|
||||
"session.websearch.title": "بحث على الويب عبر جهة خارجية",
|
||||
"session.websearch.description": "اختر موفر البحث الذي تستخدمه الوكلاء للبحث على الويب",
|
||||
"session.websearch.provider": "موفر البحث",
|
||||
@@ -820,6 +825,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "الوكلاء الفرعيون",
|
||||
"settings.timeline.category.notices": "الإشعارات",
|
||||
"settings.timeline.category.tools": "أدوات أخرى",
|
||||
"settings.general.row.mobileDiffWrap.description": "التفاف الأسطر الطويلة في فروقات الهاتف بدلًا من تمريرها أفقيًا",
|
||||
"provider.connect.error.unsupportedFields": "يحتوي نموذج المصادقة هذا على حقول غير مدعومة",
|
||||
"settings.about.writtenByNames": "كتبه \u2068{{names}}\u2069",
|
||||
"settings.about.illustratedByNames": "رسوم \u2068{{names}}\u2069",
|
||||
|
||||
@@ -298,6 +298,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "{{color}} rəngini seçin",
|
||||
"dialog.project.edit.worktree.startup": "İş sahəsi başlanğıc skripti",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "məs. bun install",
|
||||
"dialog.releaseNotes.action.getStarted": "Başla",
|
||||
"dialog.releaseNotes.action.next": "Növbəti",
|
||||
"dialog.releaseNotes.action.hideFuture": "Gələcəkdə göstərmə",
|
||||
"dialog.releaseNotes.media.alt": "Buraxılış önbaxışı",
|
||||
"dialog.usageExceeded.dontShowAgain": "Bir daha göstərmə",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "İcazələr avtomatik qəbul edilir",
|
||||
@@ -717,6 +721,7 @@ export const dict = {
|
||||
"session.view.select": "Sessiya görünüşü",
|
||||
"session.background.moveRunning": "Arxa plana keçir",
|
||||
"session.timeline.working": "İşləyir",
|
||||
"session.review.wrapLines": "Sətirləri bük",
|
||||
"session.websearch.title": "Üçüncü tərəf veb axtarışı",
|
||||
"session.websearch.description": "Agentlərin vebdə axtarış üçün istifadə edəcəyi axtarış provayderini seçin",
|
||||
"session.websearch.provider": "Axtarış provayderi",
|
||||
@@ -778,6 +783,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Alt-agentlər",
|
||||
"settings.timeline.category.notices": "Bildirişlər",
|
||||
"settings.timeline.category.tools": "Digər alətlər",
|
||||
"settings.general.row.mobileDiffWrap.description": "Mobil diff-lərdə üfüqi sürüşdürmək əvəzinə uzun sətirləri bük",
|
||||
"provider.connect.error.unsupportedFields": "Bu autentifikasiya formasında dəstəklənməyən sahələr var",
|
||||
"settings.about.writtenByNames": "Müəllif: {{names}}",
|
||||
"settings.about.illustratedByNames": "İllüstrasiyalar: {{names}}",
|
||||
|
||||
@@ -297,6 +297,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "Изберете цвят {{color}}",
|
||||
"dialog.project.edit.worktree.startup": "Скрипт за стартиране на работното пространство",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "напр. bun install",
|
||||
"dialog.releaseNotes.action.getStarted": "Започнете",
|
||||
"dialog.releaseNotes.action.next": "Следваща",
|
||||
"dialog.releaseNotes.action.hideFuture": "Не ги показвайте в бъдеще",
|
||||
"dialog.releaseNotes.media.alt": "Визуализация на изданието",
|
||||
"toast.permissions.autoaccept.on.title": "Автоматично приемане на разрешения",
|
||||
"toast.permissions.autoaccept.on.description": "Заявките за разрешение ще бъдат автоматично одобрени",
|
||||
"toast.permissions.autoaccept.off.title": "Автоматичното приемане на разрешения е спряно",
|
||||
@@ -716,6 +720,7 @@ export const dict = {
|
||||
"session.view.select": "Изглед на сесията",
|
||||
"session.background.moveRunning": "Преместване на заден план",
|
||||
"session.timeline.working": "Работи",
|
||||
"session.review.wrapLines": "Пренасяне на редовете",
|
||||
"session.websearch.title": "Уеб търсене на трети страни",
|
||||
"session.websearch.description": "Изберете доставчика на търсене, който агентите използват за търсене в мрежата",
|
||||
"session.websearch.provider": "Доставчик на търсене",
|
||||
@@ -776,6 +781,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Субагенти",
|
||||
"settings.timeline.category.notices": "Бележки",
|
||||
"settings.timeline.category.tools": "Други инструменти",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Увийте дълги редове в мобилни разлики, вместо да превъртате хоризонтално",
|
||||
"provider.connect.error.unsupportedFields": "Този формуляр за удостоверяване съдържа неподдържани полета",
|
||||
"settings.about.writtenByNames": "Написано от {{names}}",
|
||||
"settings.about.illustratedByNames": "Илюстрирано от {{names}}",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import en from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
export const dict: Record<string, string> = {
|
||||
"desktop.menu.app": "OpenCode",
|
||||
"desktop.menu.file": "ফাইল",
|
||||
"desktop.menu.edit": "সম্পাদনা করুন",
|
||||
@@ -300,6 +296,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "{{color}} রঙ নির্বাচন করুন",
|
||||
"dialog.project.edit.worktree.startup": "ওয়ার্কস্পেস স্টার্টআপ স্ক্রিপ্ট",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "যেমন বান ইনস্টল",
|
||||
"dialog.releaseNotes.action.getStarted": "শুরু করুন",
|
||||
"dialog.releaseNotes.action.next": "পরবর্তী",
|
||||
"dialog.releaseNotes.action.hideFuture": "ভবিষ্যতে এগুলো দেখাবেন না",
|
||||
"dialog.releaseNotes.media.alt": "রিলিজ পূর্বরূপ",
|
||||
"toast.permissions.autoaccept.on.title": "স্বয়ংক্রিয়ভাবে গ্রহণযোগ্য অনুমতি",
|
||||
"toast.permissions.autoaccept.on.description": "অনুমতি অনুরোধ স্বয়ংক্রিয়ভাবে অনুমোদিত হবে",
|
||||
"toast.permissions.autoaccept.off.title": "স্বয়ংক্রিয়ভাবে গ্রহণযোগ্য অনুমতি বন্ধ করা হয়েছে৷",
|
||||
@@ -713,6 +713,7 @@ export const dict = {
|
||||
"session.view.select": "সেশন দৃশ্য",
|
||||
"session.background.moveRunning": "ব্যাকগ্রাউন্ডে নিন",
|
||||
"session.timeline.working": "কাজ চলছে",
|
||||
"session.review.wrapLines": "লাইন মোড়ানো",
|
||||
"session.websearch.title": "তৃতীয়-পক্ষের ওয়েব অনুসন্ধান",
|
||||
"session.websearch.description":
|
||||
"এজেন্টরা ওয়েবে অনুসন্ধানের জন্য যে অনুসন্ধান প্রদানকারী ব্যবহার করবে সেটি বেছে নিন",
|
||||
@@ -773,6 +774,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "সাবএজেন্ট",
|
||||
"settings.timeline.category.notices": "বিজ্ঞপ্তি",
|
||||
"settings.timeline.category.tools": "অন্যান্য টুল",
|
||||
"settings.general.row.mobileDiffWrap.description": "মোবাইল ডিফে অনুভূমিকভাবে স্ক্রল করার বদলে দীর্ঘ লাইন মোড়ান",
|
||||
|
||||
"provider.connect.error.unsupportedFields": "এই প্রমাণীকরণ ফর্মে অসমর্থিত ফিল্ড রয়েছে",
|
||||
"settings.about.writtenByNames": "লিখেছেন {{names}}",
|
||||
@@ -896,4 +898,4 @@ export const dict = {
|
||||
"prompt.toast.uploading.percent": "{{percent}}%",
|
||||
"prompt.toast.uploading.cancelFile": "{{filename}}-এর আপলোড বাতিল করুন",
|
||||
"prompt.toast.uploadFailed.title": "আপলোড ব্যর্থ হয়েছে",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export const dict = {
|
||||
"session.view.select": "Visualização da sessão",
|
||||
"session.background.moveRunning": "Mover para segundo plano",
|
||||
"session.timeline.working": "Trabalhando",
|
||||
"session.review.wrapLines": "Quebrar linhas",
|
||||
"session.websearch.title": "Pesquisa na web de terceiros",
|
||||
"session.websearch.description": "Selecione o provedor de pesquisa que os agentes usam para pesquisar na web",
|
||||
"session.websearch.provider": "Provedor de pesquisa",
|
||||
@@ -192,6 +193,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Subagentes",
|
||||
"settings.timeline.category.notices": "Avisos",
|
||||
"settings.timeline.category.tools": "Outras ferramentas",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Quebrar linhas longas nos diffs para dispositivos móveis em vez de rolar horizontalmente",
|
||||
"session.background.shell.many": "{{count}} shells",
|
||||
"session.background.subagent.many": "{{count}} subagentes",
|
||||
"settings.about.otherContributor.many": "{{count}} outros",
|
||||
@@ -793,6 +796,10 @@ export const dict = {
|
||||
"workspace.delete.confirm": 'Excluir espaço de trabalho "{{name}}"?',
|
||||
"workspace.delete.button": "Excluir espaço de trabalho",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Começar",
|
||||
"dialog.releaseNotes.action.next": "Próximo",
|
||||
"dialog.releaseNotes.action.hideFuture": "Não mostrar isso no futuro",
|
||||
"dialog.releaseNotes.media.alt": "Prévia do lançamento",
|
||||
"toast.project.reloadFailed.title": "Falha ao recarregar {{project}}",
|
||||
"error.server.invalidConfiguration": "Configuração inválida",
|
||||
"common.moreCountSuffix": " (+{{count}} mais)",
|
||||
|
||||
@@ -624,6 +624,10 @@ export const dict = {
|
||||
"workspace.delete.confirm": 'Izbriši radni prostor "{{name}}"?',
|
||||
"workspace.delete.button": "Izbriši radni prostor",
|
||||
"common.open": "Otvori",
|
||||
"dialog.releaseNotes.action.getStarted": "Započni",
|
||||
"dialog.releaseNotes.action.next": "Sljedeće",
|
||||
"dialog.releaseNotes.action.hideFuture": "Ne prikazuj ovo u budućnosti",
|
||||
"dialog.releaseNotes.media.alt": "Pregled izdanja",
|
||||
"toast.project.reloadFailed.title": "Nije uspjelo ponovno učitavanje {{project}}",
|
||||
"error.server.invalidConfiguration": "Nevažeća konfiguracija",
|
||||
"common.moreCountSuffix": " (+{{count}} više)",
|
||||
@@ -779,6 +783,7 @@ export const dict = {
|
||||
"session.view.select": "Prikaz sesije",
|
||||
"session.background.moveRunning": "Premjesti u pozadinu",
|
||||
"session.timeline.working": "U tijeku",
|
||||
"session.review.wrapLines": "Prelamaj retke",
|
||||
"session.websearch.title": "Web pretraga treće strane",
|
||||
"session.websearch.description": "Odaberite provajdera pretraživanja koje agenti koriste za pretraživanje weba",
|
||||
"session.websearch.provider": "Provajder pretraživanja",
|
||||
@@ -838,6 +843,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Podagenti",
|
||||
"settings.timeline.category.notices": "Napomene",
|
||||
"settings.timeline.category.tools": "Ostali alati",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Umotajte dugačke redove u mobilne razlike umjesto horizontalnog pomicanja",
|
||||
"session.background.shell.few": "{{count}} ljuske",
|
||||
"session.background.subagent.few": "{{count}} podagenta",
|
||||
"settings.about.otherContributor.few": "{{count}} druga",
|
||||
|
||||
@@ -296,6 +296,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "Seleccioneu el color {{color}}.",
|
||||
"dialog.project.edit.worktree.startup": "Script d'inici de l'espai de treball",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "p. ex. bun install",
|
||||
"dialog.releaseNotes.action.getStarted": "Comença",
|
||||
"dialog.releaseNotes.action.next": "A continuació",
|
||||
"dialog.releaseNotes.action.hideFuture": "No mostris aquestes en el futur",
|
||||
"dialog.releaseNotes.media.alt": "Allibera la vista prèvia",
|
||||
"toast.permissions.autoaccept.on.title": "Permisos d'acceptació automàtica",
|
||||
"toast.permissions.autoaccept.on.description": "Les sol·licituds de permís s'aprovaran automàticament",
|
||||
"toast.permissions.autoaccept.off.title": "S'ha aturat l'acceptació automàtica de permisos",
|
||||
@@ -719,6 +723,7 @@ export const dict = {
|
||||
"session.view.select": "Visualització de sessió",
|
||||
"session.background.moveRunning": "Vés al fons",
|
||||
"session.timeline.working": "Treballant",
|
||||
"session.review.wrapLines": "Ajusta les línies",
|
||||
"session.websearch.title": "Cerca web de tercers",
|
||||
"session.websearch.description": "Selecciona els agents proveïdors de cerca que utilitzen per cercar al web",
|
||||
"session.websearch.provider": "Proveïdor de cerca",
|
||||
@@ -779,6 +784,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Subagents",
|
||||
"settings.timeline.category.notices": "Avisos",
|
||||
"settings.timeline.category.tools": "Altres eines",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Ajustar les línies llargues en diffs mòbils en lloc de desplaçar-se horitzontalment",
|
||||
|
||||
"session.background.shell.many": "{{count}} shells",
|
||||
"session.background.subagent.many": "{{count}} subagents",
|
||||
|
||||
@@ -296,6 +296,10 @@ export const dict = {
|
||||
"dialog.project.edit.color.select": "Vyberte barvu {{color}}",
|
||||
"dialog.project.edit.worktree.startup": "Spouštěcí skript pracovního prostoru",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "např. bun install",
|
||||
"dialog.releaseNotes.action.getStarted": "Začněte",
|
||||
"dialog.releaseNotes.action.next": "Další",
|
||||
"dialog.releaseNotes.action.hideFuture": "V budoucnu je nezobrazujte",
|
||||
"dialog.releaseNotes.media.alt": "Náhled vydání",
|
||||
"toast.permissions.autoaccept.on.title": "Automatické přijímání oprávnění",
|
||||
"toast.permissions.autoaccept.on.description": "Žádosti o povolení budou automaticky schváleny",
|
||||
"toast.permissions.autoaccept.off.title": "Zastaveno automatické přijímání oprávnění",
|
||||
@@ -721,6 +725,7 @@ export const dict = {
|
||||
"session.view.select": "Zobrazení relace",
|
||||
"session.background.moveRunning": "Přesun na pozadí",
|
||||
"session.timeline.working": "Pracuje",
|
||||
"session.review.wrapLines": "Zalamovat řádky",
|
||||
"session.websearch.title": "Webové vyhledávání třetích stran",
|
||||
"session.websearch.description": "Vyberte agenty poskytovatele vyhledávání, které používají k vyhledávání na webu",
|
||||
"session.websearch.provider": "Poskytovatel vyhledávání",
|
||||
@@ -779,6 +784,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "subagenty",
|
||||
"settings.timeline.category.notices": "Upozornění",
|
||||
"settings.timeline.category.tools": "Jiné nářadí",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Zabalte dlouhé řádky do mobilních diffů namísto vodorovného posouvání",
|
||||
"session.background.shell.few": "{{count}} shelly",
|
||||
"session.background.shell.many": "{{count}} shellu",
|
||||
"session.background.subagent.few": "{{count}} podagenti",
|
||||
|
||||
@@ -121,6 +121,7 @@ export const dict = {
|
||||
"session.view.select": "Sessionsvisning",
|
||||
"session.background.moveRunning": "Flyt til baggrunden",
|
||||
"session.timeline.working": "Arbejder",
|
||||
"session.review.wrapLines": "Ombryd linjer",
|
||||
"session.websearch.title": "Websøgning fra tredjepart",
|
||||
"session.websearch.description": "Vælg den søgeudbyder, som agenter bruger til at søge på nettet",
|
||||
"session.websearch.provider": "Søgeudbyder",
|
||||
@@ -179,6 +180,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Underagenter",
|
||||
"settings.timeline.category.notices": "Meddelelser",
|
||||
"settings.timeline.category.tools": "Andre værktøjer",
|
||||
"settings.general.row.mobileDiffWrap.description": "Ombryd lange linjer i mobile diffs i stedet for at rulle vandret",
|
||||
"toast.migration.progress.clearingOldEvents": "Rydder gamle hændelser",
|
||||
"toast.migration.progress.migratingSessions": "Migrerer sessioner",
|
||||
"session.new.workspace.existingLabel": "Worktree",
|
||||
@@ -757,6 +759,10 @@ export const dict = {
|
||||
"workspace.delete.confirm": 'Slet arbejdsområde "{{name}}"?',
|
||||
"workspace.delete.button": "Slet arbejdsområde",
|
||||
"common.open": "Åbn",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Næste",
|
||||
"dialog.releaseNotes.action.hideFuture": "Vis ikke disse i fremtiden",
|
||||
"dialog.releaseNotes.media.alt": "Forhåndsvisning af udgivelse",
|
||||
"toast.project.reloadFailed.title": "Kunne ikke genindlæse {{project}}",
|
||||
"error.server.invalidConfiguration": "Ugyldig konfiguration",
|
||||
"common.moreCountSuffix": " (+{{count}} mere)",
|
||||
|
||||
@@ -136,6 +136,7 @@ export const dict = {
|
||||
"session.view.select": "Sitzungsansicht",
|
||||
"session.background.moveRunning": "In den Hintergrund verschieben",
|
||||
"session.timeline.working": "In Arbeit",
|
||||
"session.review.wrapLines": "Zeilen umbrechen",
|
||||
"session.websearch.title": "Websuche von Drittanbietern",
|
||||
"session.websearch.description": "Wählen Sie den Suchanbieter aus, den Agenten für die Websuche verwenden",
|
||||
"session.websearch.provider": "Suchanbieter",
|
||||
@@ -197,6 +198,8 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Unteragenten",
|
||||
"settings.timeline.category.notices": "Hinweise",
|
||||
"settings.timeline.category.tools": "Andere Tools",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Lange Zeilen in mobilen Diffs umbrechen, statt horizontal zu scrollen",
|
||||
"toast.migration.progress.clearingOldEvents": "Alte Ereignisse werden gelöscht",
|
||||
"toast.migration.progress.migratingSessions": "Sitzungen werden migriert",
|
||||
"session.new.workspace.existingLabel": "Worktree",
|
||||
@@ -730,6 +733,10 @@ export const dict = {
|
||||
"workspace.delete.confirm": 'Arbeitsbereich "{{name}}" löschen?',
|
||||
"workspace.delete.button": "Arbeitsbereich löschen",
|
||||
"common.open": "Öffnen",
|
||||
"dialog.releaseNotes.action.getStarted": "Loslegen",
|
||||
"dialog.releaseNotes.action.next": "Weiter",
|
||||
"dialog.releaseNotes.action.hideFuture": "In Zukunft nicht mehr anzeigen",
|
||||
"dialog.releaseNotes.media.alt": "Vorschau auf die Version",
|
||||
"toast.project.reloadFailed.title": "Fehler beim Neuladen von {{project}}",
|
||||
"error.server.invalidConfiguration": "Ungültige Konfiguration",
|
||||
"common.moreCountSuffix": " (+{{count}} weitere)",
|
||||
|
||||
Loaded 100 of 885 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user