Compare commits

..
Author SHA1 Message Date
Kit Langton 3913203f0f feat(server): stream fs.read responses with HTTP Range support 2026-10-03 22:17:16 -07:00
Kit Langton 2884b49f8f refactor(tui): remove permanently disabled unshare command (#53003) 2026-10-03 21:34:00 -07:00
Kit Langton b79da2697a refactor(tui): drop unused ComposerTab.onClose and composer re-export (#53002) 2026-10-03 21:33:55 -07:00
Kit Langton 552d2b9c8a refactor(tui): drop never-passed component props (#53001) 2026-10-03 21:33:51 -07:00
Kit Langton 0d98734fb2 refactor(tui): share mini requestOptions helper (#53000) 2026-10-03 21:33:47 -07:00
Kit Langton 5ac5027cce refactor(tui): remove unused mini thinking override (#52999) 2026-10-03 21:33:42 -07:00
Kit Langton 4cb7ac9dab refactor(tui): drop constant mini frontend exit code (#52998) 2026-10-03 21:33:37 -07:00
Kit Langton bc499e4562 refactor(tui): remove unused monoTruncateMiddle (#52997) 2026-10-03 21:33:33 -07:00
Kit Langton fa373ddb51 refactor(tui): share clamp between tab pulse and fade-in text (#52996) 2026-10-03 21:33:28 -07:00
Kit Langton 2a02be02a7 refactor(tui): remove unused TabPulse outerFlashTail prop (#52995) 2026-10-03 21:33:23 -07:00
Kit Langton fc224dc8f0 refactor(tui): remove unused DialogSelect details and category view options (#52994) 2026-10-03 21:33:18 -07:00
Kit Langton eec8cf912e refactor(tui): remove unused Prompt hint, right, and showPlaceholder props (#52993) 2026-10-03 21:33:13 -07:00
Kit Langton 86f9edc107 refactor(tui): remove unread terminal environment fields (#52991) 2026-10-03 21:33:08 -07:00
Kit Langton 5d6804eaac refactor(tui): remove unused v1 config aliases and keybind values (#52990) 2026-10-03 21:33:03 -07:00
Kit Langton 697ca1c9d3 refactor(tui): drop unused theme re-exports (#52986) 2026-10-03 21:32:58 -07:00
Kit Langton 1287996a91 refactor(tui): drop stale terminal-win32 export (#52985) 2026-10-03 21:32:54 -07:00
Kit Langton a30f24355e refactor(tui): share file change status label (#53004) 2026-10-03 23:50:25 -04:00
Jérôme Benoit 96ecd60bdb fix(nix): provide desktop signing tools on darwin (#53007) 2026-10-03 22:33:12 -05:00
Aiden Cline 15c4e5d537 feat(ai): add Google Interactions protocol (#52981) 2026-10-03 22:22:58 -05:00
Aiden Cline 5617044dc9 refactor(ai): avoid redundant request body validation (#52909) 2026-10-03 22:22:15 -05:00
Luke Parker 0048c97c27 feat(gui-extensions): add typed composition and lifetime primitives (#52868) 2026-10-04 13:16:37 +10:00
opencode-agent[bot]andHona f72a103a4b fix(windows): hide background subprocess windows (#52871)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-10-04 11:20:47 +10:00
opencode-agent[bot] f74512bfe0 chore: update nix node_modules hashes 2026-10-03 23:05:19 +00:00
opencode-agent[bot]andneriousy 63b7de9434 fix(cli): restore Linux x64 musl PTY lock entry (#53035)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-10-04 00:55:38 +02:00
Filip e8c75c2de0 fix(cli): run the curl installer with Git Bash on Windows (#53029) 2026-10-04 00:29:09 +02:00
opencode-agent[bot] d1f8f5b35e chore: update nix node_modules hashes 2026-10-03 18:57:44 +00:00
Kit Langton 334f7a3510 fix(core): keep the first PTY output after spawn (#52960) 2026-10-03 11:51:01 -07:00
opencode-agent[bot] f02e184ba4 chore: update nix node_modules hashes 2026-10-03 14:32:42 +00:00
James Long 5ad0786536 fix(core): never let persistent PTY handoff break a server restart (#52573) 2026-10-03 10:23:31 -04:00
opencode-agent[bot] d8a3962923 chore(core): refresh bundled models.dev snapshot 2026-10-03 13:46:03 +00:00
Simon Klee 40679546d4 fix(tui): keep transcript rows stable when history is prepended (#52824)
Session rows have no id, so reconcile matched them by position. Each page of older history shifted every row into a different store object and remounted the visible transcript. Newly mounted markdown is blank until highlighting finishes, so the transcript blinked once per page. This was most visible with Low verbosity, where opening a session loads several pages to complete the oldest group.
2026-10-03 06:09:37 +00:00
Daniel Chen 12810e84d3 docs(www): add Fledge Alpha Free to Console models (#52896) 2026-10-02 21:59:24 -07:00
Aiden Cline 3e1aa1e58c fix(server): return 404 when a location folder is missing (#52668) 2026-10-02 23:43:56 -05:00
Aiden Cline 7ced9326e6 fix(ai): preserve Mistral and Cohere system parts (#52890) 2026-10-02 23:36:36 -05:00
Aiden Cline d1ff6f90cf fix(ai): preserve Gemini system text parts (#52888) 2026-10-02 23:04:49 -05:00
James Long d9d094a543 fix(tui): keep question form highlights on the raised surface (#52872) 2026-10-02 20:34:12 -04:00
Kit Langton d113a40f55 fix(core): rename legacy provider in top-level model (#51901) 2026-10-02 16:02:13 -07:00
Kit Langton 54956aac22 chore: enable noUnusedLocals in ai and core (#52858) 2026-10-02 22:45:27 +00:00
Kit Langton c1536734ce chore(cli): enable noUnusedLocals (#52857) 2026-10-02 22:45:05 +00:00
Kit Langton cf7fcfad0a chore(app): enable noUnusedLocals (#52856) 2026-10-02 22:44:02 +00:00
Kit Langton 17955a1100 refactor(core): remove duplicate v1 config migration (#51889) 2026-10-02 15:40:41 -07:00
Kit Langton c0c9c69a27 chore(tui): enable noUnusedLocals (#52851) 2026-10-02 22:36:03 +00:00
Kit Langton eb1a9ea4d9 chore: enable noUnusedLocals in schema, server, sdk and smaller packages (#52850) 2026-10-02 15:23:59 -07:00
Kit Langton 680bca3183 chore: enable noUnusedLocals in already-clean packages (#52849) 2026-10-02 15:23:50 -07:00
Kit Langton 22f81eed0b test(tui): drop title shimmer animation timing tests (#52848) 2026-10-02 15:23:31 -07:00
Aiden Cline 421a04f5fd fix(core): retry transient MCP connect failures (#52614) 2026-10-02 17:22:25 -05:00
Jérôme BenoitandTest User ec541abbcd chore(nix): run the nix eval workflow on v2 (#52143)
Co-authored-by: Test User <test@test.com>
2026-10-02 16:52:46 -05:00
Kit Langton 4c63a35dbe refactor(schema): drop unused FileAttachment re-export from SessionEvent (#52842) 2026-10-02 21:35:12 +00:00
Kit Langton 0e4dd7bfe9 refactor(ai): remove unused declarations in OpenAI chat protocol (#52840) 2026-10-02 21:33:58 +00:00
Kit Langton e28a624b8b refactor(core): remove unused git strategy in worktree layer (#52841) 2026-10-02 21:28:27 +00:00
Kit Langton 9277761af6 refactor(schema): remove unused QuestionV1 contract (#52838) 2026-10-02 21:28:13 +00:00
Kit Langton 0bef0eab67 refactor(core): remove legacy id prefix table (#52810) 2026-10-02 18:40:26 +00:00
Shoubhit Dash 66d15a7352 refactor(acp): split module seams and drop dead code (#52803) 2026-10-03 00:04:06 +05:30
Kit Langton 9f8551ab6b refactor(core): require job ids from their subject (#52806) 2026-10-02 18:27:13 +00:00
Kit Langton 8bcda4c718 refactor(core): name tool output files in ToolOutput (#52808) 2026-10-02 18:24:57 +00:00
Kit Langton ccd2aca07a test(core): match Code Mode description after allSettled change (#52812) 2026-10-02 18:14:27 +00:00
1391c0d52c docs(core): recommend settled results for independent calls (#52798)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-10-02 12:26:05 -05:00
Shoubhit Dash ec90ad50d3 fix(acp): settle permissions replied elsewhere and type request failures (#52774) 2026-10-02 22:43:32 +05:30
Shoubhit Dash 2a01ed60b1 perf(core): keep unchanged messages in request shaping (#52784) 2026-10-02 22:12:21 +05:30
Shoubhit Dash 80caae8ad1 test(acp): keep only spec and essential tests (#52775) 2026-10-02 22:01:49 +05:30
885 changed files with 24174 additions and 24492 deletions

No files matched your search

+11
View File
@@ -18,9 +18,20 @@ 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 -2
View File
@@ -2,9 +2,9 @@ name: nix-eval
on:
push:
branches: [dev]
branches: [dev, v2]
pull_request:
branches: [dev]
branches: [dev, v2]
workflow_dispatch:
concurrency:
@@ -1,105 +0,0 @@
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
+9
View File
@@ -252,6 +252,13 @@ 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
@@ -264,3 +271,5 @@ 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
+52
View File
@@ -63,8 +63,60 @@
"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",
+11 -48
View File
@@ -110,31 +110,6 @@
"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",
@@ -147,7 +122,7 @@
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -380,7 +355,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -390,7 +365,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -2192,19 +2167,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.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": ["@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-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
"@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-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-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
"@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-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
"@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-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
"@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/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=="],
@@ -2214,8 +2189,6 @@
"@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"],
@@ -3138,8 +3111,6 @@
"@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=="],
@@ -3212,18 +3183,12 @@
"@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=="],
@@ -3584,7 +3549,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
@@ -6256,8 +6221,6 @@
"@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=="],
+2
View File
@@ -40,6 +40,8 @@ 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
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
"x86_64-linux": "sha256-2RlbJRTEKSliuUbAE2lAktX63JFR/RKAuLkcCou8wb4=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
}
}
+2 -1
View File
@@ -18,7 +18,8 @@
"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",
"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: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",
+25
View File
@@ -27,6 +27,31 @@ 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, optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, ProviderShared } from "./shared.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
@@ -25,8 +25,6 @@ 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,
@@ -52,7 +50,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 yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
return {
...body,
enable_thinking: opts.enableThinking,
previous_response_id: opts.previousResponseId,
@@ -62,7 +60,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: {
+9 -1
View File
@@ -124,7 +124,15 @@ 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: ProviderShared.joinText(request.system) }]
? [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
+1 -1
View File
@@ -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: [{ text: ProviderShared.joinText(request.system) }] },
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
tools: hasTools
? [
{
@@ -0,0 +1,561 @@
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"
+1
View File
@@ -2,6 +2,7 @@ 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"
+5 -27
View File
@@ -1,6 +1,5 @@
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"
@@ -44,13 +43,6 @@ 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) })),
})
@@ -62,12 +54,13 @@ 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)
const projected = ProviderShared.flattenToolRequest(
return yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, {
messages: request.messages.map((message) =>
Message.make({
@@ -93,23 +86,8 @@ 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 = {
@@ -222,7 +200,7 @@ const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, inpu
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: Body, from: fromRequest },
body: { schema: OpenResponses.OpenResponsesBody, from: fromRequest },
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request): ParserState => ({ ...OpenResponses.initial(request, adapter), completedItems: new Set() }),
@@ -231,6 +209,6 @@ export const protocol = Protocol.make({
},
})
export const httpTransport = HttpTransport.sseJson.with<Schema.Schema.Type<typeof Body>>()
export const httpTransport = OpenResponses.httpTransport
export * as MetaResponses from "./meta-responses.js"
+15 -2
View File
@@ -68,7 +68,10 @@ const MistralAssistantToolCall = Schema.Struct({
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
@@ -335,7 +338,17 @@ 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: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
+29 -30
View File
@@ -168,10 +168,20 @@ export const ConfigurationUpdate = Schema.Struct({
type: Schema.Literal("configuration_update"),
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
})
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
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>
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({
@@ -204,24 +214,9 @@ 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.
@@ -239,6 +234,14 @@ 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 }),
@@ -257,7 +260,7 @@ export const coreFields = {
model: Schema.String,
input: Schema.Array(InputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tools: optionalArray(Schema.Union([Tool, HostedTool])),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -292,7 +295,7 @@ export const coreFields = {
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
export const OpenResponsesBody = Schema.Struct({
...coreFields,
stream: Schema.Literal(true),
})
@@ -397,9 +400,7 @@ export const decodeChannelEvent = (frame: string) =>
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly nativeTool?: (
native: NonNullable<ToolDefinition["native"]>,
) => Effect.Effect<{ readonly type: string }, AIError>
readonly nativeTool?: (native: NonNullable<ToolDefinition["native"]>) => Effect.Effect<HostedTool, AIError>
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: Media.Inline | undefined
@@ -455,8 +456,10 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protoco
})
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
Effect.forEach(tools, (tool) =>
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
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),
)
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -592,7 +595,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const input: OpenResponsesInputItem[] = []
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
@@ -827,11 +830,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
export const fromRequest = (request: LLMRequest) => fromRequestWithAdapter(request, BASE_ADAPTER)
// =============================================================================
// Stream Parsing
-7
View File
@@ -14,7 +14,6 @@ import {
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type LLMRequest,
@@ -46,12 +45,6 @@ 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({
+3 -12
View File
@@ -103,15 +103,8 @@ 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(
@@ -134,7 +127,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([OpenAIResponsesInputItem, CompactionTrigger])),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, CompactionTrigger])),
})
const adapter = {
@@ -202,15 +195,13 @@ 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 yield* decodeBody({
return {
...(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 })),
@@ -220,7 +211,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
? undefined
: (OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
})
}
})
const checkpointBody = {
+2 -9
View File
@@ -31,19 +31,12 @@ 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({
@@ -53,7 +46,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* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* OpenResponses.fromRequestWithAdapter(request, adapter)
})
const HOSTED_TOOLS = {
@@ -83,7 +76,7 @@ const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: XAIResponsesBody,
schema: OpenResponses.OpenResponsesBody,
from: fromRequest,
},
stream: {
+12 -2
View File
@@ -5,6 +5,7 @@ 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"
@@ -16,15 +17,16 @@ 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]
export const routes = [Gemini.route, GoogleInteractions.route]
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
readonly providerOptions?: Gemini.ProviderOptionsInput & GoogleInteractions.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
@@ -45,12 +47,19 @@ 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 }),
@@ -73,6 +82,7 @@ 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
@@ -0,0 +1,21 @@
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)
+2 -5
View File
@@ -358,7 +358,6 @@ 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(
@@ -417,7 +416,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
request,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
encodeBody: ProviderShared.encodeJson,
middleware: options?.http,
webSocket: options?.webSocket,
}),
@@ -576,9 +575,7 @@ 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)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const body = yield* route.body.from(resolved)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
-1
View File
@@ -14,7 +14,6 @@ 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"
@@ -6,7 +6,7 @@
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-02T02:36:16.143Z"
"recordedAt": "2026-10-03T04:09:51.600Z"
},
"interactions": [
{
@@ -17,14 +17,14 @@
"headers": {
"content-type": "application/json"
},
"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}"
"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}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"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
"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
}
},
{
@@ -35,14 +35,14 @@
"headers": {
"content-type": "application/json"
},
"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}"
"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}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"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
"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
}
}
]
@@ -0,0 +1,66 @@
{
"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": "{}"
}
}
]
}
@@ -0,0 +1,50 @@
{
"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"
}
}
]
}
@@ -0,0 +1,32 @@
{
"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
}
}
]
}
@@ -0,0 +1,32 @@
{
"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,9 +2,16 @@
"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-08-30T17:18:49.552Z"
"recordedAt": "2026-10-03T04:09:49.878Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"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\"}"
"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\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"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"
"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"
}
},
{
@@ -33,14 +40,14 @@
"headers": {
"content-type": "application/json"
},
"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\"}"
"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\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"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"
"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"
}
}
]
+11
View File
@@ -430,6 +430,7 @@ 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",
@@ -443,6 +444,16 @@ 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 } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, SystemPart } 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,12 +55,20 @@ recorded.effect(
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
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."),
],
},
),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
+9 -3
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, 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: "Be concise.",
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
@@ -44,7 +44,13 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{ role: "system", content: "Be concise." },
{
role: "system",
content: [
{ type: "text", text: "Be concise.\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
{
role: "assistant",
@@ -0,0 +1,169 @@
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, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, SystemPart, 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: "Initial",
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.system("Updated"),
Message.user([
@@ -105,7 +105,13 @@ describe("Mistral Chat", () => {
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{
role: "system",
content: [
{ type: "text", text: "Initial\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ 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, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, 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,7 +97,10 @@ describe("Mistral recorded", () => {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
system: [
SystemPart.make("Call lookup_weather exactly once with Paris."),
SystemPart.make("After the tool result, describe the weather briefly."),
],
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
@@ -2,6 +2,7 @@ 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"),
@@ -13,49 +14,74 @@ 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 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)
"keeps the browser tabs while the pane's Ipc is away and registers them again when it returns",
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"])
// 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")
await expect(tree).toHaveText('{"tab":"changes"}')
// 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"])
},
)
@@ -2,23 +2,25 @@ 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"),
surface: source("../src/runtime/extension/surface.tsx"),
embeds: source("../src/runtime/extension/embeds.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 surface.
// Any story loads the app styles; the fixture mounts the pane beside it on the real host embeds.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserPane }, { createSurfaces }, language] = await Promise.all([
const [{ mountBrowserPane }, { createEmbeds }, language] = await Promise.all([
import(modules.fixture),
import(modules.surface),
import(modules.embeds),
import(modules.language),
])
mountBrowserPane({
createSurfaces,
createEmbeds,
LanguageProvider: language.LanguageProvider,
UiI18nBridge: language.UiI18nBridge,
useLanguage: language.useLanguage,
@@ -26,6 +28,7 @@ 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")
@@ -50,11 +53,14 @@ 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()
@@ -108,6 +114,19 @@ 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 }) => {
@@ -157,6 +176,7 @@ 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)
@@ -0,0 +1,84 @@
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,21 +1,138 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import type { Setup } from "@opencode/gui-extensions/sdk"
import { createSignal } from "solid-js"
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 { render } from "solid-js/web"
import { ExtensionHostProvider, useExtensionHost } from "../src/runtime/extension/host"
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 { 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 }>[] = []
const loads: PromiseWithResolvers<{ default: Setup<Definition> }>[] = []
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
const state = { host: undefined as ReturnType<typeof useExtensionHost> | undefined }
const hosts: ExtensionHost[] = []
const container = document.createElement("div")
document.body.appendChild(container)
function Capture() {
state.host = useExtensionHost()
hosts.push(useExtensionHost())
return null
}
const dispose = render(
() => (
<LanguageProvider locale="en">
@@ -25,14 +142,16 @@ export function mountExtensionHost() {
{
id: "fixture",
renderer: () => {
const load = Promise.withResolvers<{ default: Setup }>()
const load = Promise.withResolvers<{ default: Setup<Definition> }>()
loads.push(load)
return load.promise
},
},
]}
disabled={disabled}
services={[]}
apis={fakeApis(unused)}
whenMounted={mounted}
>
<Capture />
</ExtensionHostProvider>
@@ -41,22 +160,359 @@ export function mountExtensionHost() {
),
container,
)
return {
unmount: () => {
dispose()
container.remove()
},
/** Resolves the nth renderer load (the first by default) with this setup. */
load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }),
load: (setup: Setup<Definition>, index = 0) => loads[index].resolve({ default: setup }),
/** Rejects the nth renderer load. */
fail: (index: number, error: unknown) => loads[index].reject(error),
fail: (index: number, cause: unknown) => loads[index].reject(cause),
/** Renderer loads requested so far. */
count: () => loads.length,
reload: () => state.host?.reload("fixture"),
reload: () => hosts[0]?.reload("fixture"),
disable: () => setDisabled(new Set(["fixture"])),
enable: () => setDisabled(new Set<string>()),
status: () => state.host?.state.status.fixture,
status: () => hosts[0]?.state.status.fixture,
/** Contributions the host holds for a point; readable after the host unmounts. */
entries: (point: string) => state.host?.state.entries[point]?.length ?? 0,
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()
},
}
}
@@ -1,9 +1,19 @@
import { fileURLToPath } from "node:url"
import type { Context, Dialogs } from "@opencode/gui-extensions/sdk"
import type { Owner } from "solid-js"
import type { Context, Contract, DialogHandle, Dialogs, SessionRef } 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")
@@ -17,35 +27,41 @@ 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 } = await import(fixture)
const { mountExtensionHost, onCleanup } = 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 }
return { before, after: host.entries(point.id), cleaned, aborted: resumed.aborted }
}, fixture)
expect(result).toEqual({ before: 1, after: 0, cleaned: ["registered", "returned"] })
expect(result).toEqual({ before: 1, after: 0, cleaned: ["owner"], aborted: true })
})
story("older loads neither set up nor fail over the replacement after reloads", async ({ page }) => {
@@ -62,8 +78,10 @@ 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" })
})
@@ -71,8 +89,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 services: Dialogs[] = []
const setup = (ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs)
const dialogs: Dialogs[] = []
const setup = (ctx: Context) => void dialogs.push(ctx.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))
@@ -81,37 +99,248 @@ 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] = services
stale.push(text("stale dialog"))
fresh.push(text("fresh dialog"))
const [stale, fresh] = dialogs
const kept = stale.open(text("stale dialog"))
fresh.open(text("fresh dialog"))
await wait(50)
stale.close()
kept.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 services: Dialogs[] = []
const dialogs: Dialogs[] = []
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
host.load((ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs), 0)
host.load((ctx: Context) => void dialogs.push(ctx.dialogs), 0)
await wait(20)
services[0].push(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" }))
dialogs[0].open(() => 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)
@@ -124,6 +353,7 @@ 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)
@@ -133,7 +363,196 @@ 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,7 +1,6 @@
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"
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
@@ -15,7 +15,6 @@ import {
partUpdated,
renderedPartID,
setupTimeline,
shell,
status,
textPart,
toolPart,
@@ -1,4 +1,4 @@
import type { CDPSession, Page } from "@playwright/test"
import type { Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
@@ -9,16 +9,32 @@ 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```",
}
@@ -65,8 +81,21 @@ 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"].filter((path) => path.includes(query)).map((path) => fileNode(fixture.directory, path)),
["first.ts", "second.ts"].flatMap((path) => (path.includes(query) ? [fileNode(fixture.directory, path)] : [])),
})
await page.goto(sessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
@@ -21,15 +21,19 @@ 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"]')
@@ -111,6 +115,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
],
fileContent: async (path) => {
if (path === "modified.ts") await held.promise
return `contents:${path}`
},
})
@@ -139,11 +144,13 @@ 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()
@@ -154,9 +161,11 @@ 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"]')
@@ -177,8 +186,12 @@ 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("settings.v3") ?? "{}").general?.mobileDiffWrap)
page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.review.mobileDiff") ?? "{}").wrap,
)
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
@@ -214,6 +227,7 @@ 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)
@@ -221,12 +235,14 @@ 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()
}
@@ -235,9 +251,11 @@ 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"]')
@@ -251,9 +269,13 @@ 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)
@@ -262,6 +284,7 @@ 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()
+90 -8
View File
@@ -12,11 +12,13 @@ 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()
@@ -35,6 +37,7 @@ 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")],
@@ -49,6 +52,7 @@ 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: {
@@ -90,6 +94,8 @@ 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" },
}),
)
@@ -142,9 +148,10 @@ 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) => ((element as HTMLElement & { e2eProbe?: string }).e2eProbe = "original"))
await root.evaluate((element) => void (element.dataset.e2eProbe = "original"))
const expectSidebarKept = async () => {
expect(await root.evaluate((element) => (element as HTMLElement & { e2eProbe?: string }).e2eProbe)).toBe("original")
expect(await root.evaluate((element) => element.dataset.e2eProbe)).toBe("original")
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
}
@@ -269,7 +276,9 @@ 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"),
@@ -371,6 +380,7 @@ 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 } },
{
@@ -382,6 +392,7 @@ 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: () => [],
@@ -405,6 +416,9 @@ 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"
@@ -418,12 +432,16 @@ 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 []
},
})
@@ -432,27 +450,37 @@ 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 = { problems: [] as string[], frames: 0, running: true }
const probe: ImageProbe = { problems: [], 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) => typeof problem === "string")
].filter((problem) => problem !== false && problem !== null)
probe.problems.push(...frame)
probe.frames += 1
if (probe.running) requestAnimationFrame(sample)
}
;(window as Window & { e2eImageProbe?: typeof probe }).e2eImageProbe = probe
// The evaluates below read it.
Object.assign(window, { e2eImageProbe: probe })
requestAnimationFrame(sample)
})
const frames = () =>
page.evaluate(() => (window as Window & { e2eImageProbe?: { frames: number } }).e2eImageProbe!.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)
const response = page.waitForResponse((item) => item.url().includes(`/api/fs/read/${image}`))
await page.getByRole("button", { name: /preview\.png/ }).click()
await read.promise
@@ -465,8 +493,10 @@ test("image files keep the review panel painted while loading", async ({ page })
await expect.poll(frames).toBeGreaterThan(loaded)
expect(
await page.evaluate(() => {
const probe = (window as Window & { e2eImageProbe?: { problems: string[]; running: boolean } }).e2eImageProbe!
// SAFETY: the evaluate above stored the probe on the window.
const probe = (window as Window & { e2eImageProbe?: ImageProbe }).e2eImageProbe!
probe.running = false
return probe.problems
}),
).toEqual([])
@@ -478,6 +508,7 @@ 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",
@@ -493,10 +524,13 @@ 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)
@@ -603,6 +637,7 @@ 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}"]`)
@@ -643,8 +678,10 @@ 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,
@@ -653,6 +690,7 @@ 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 })
@@ -686,6 +724,36 @@ 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 })
@@ -708,6 +776,7 @@ 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 &&
@@ -719,6 +788,7 @@ 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)
@@ -731,7 +801,9 @@ 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) => {
;(element as HTMLButtonElement).click()
// SAFETY: `toggle` is the review toggle, a button.
const button = element as HTMLButtonElement
button.click()
document
.getAnimations()
.filter((animation) => animation.timeline instanceof DocumentTimeline)
@@ -739,6 +811,7 @@ 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
@@ -747,6 +820,7 @@ for (const direction of ["ltr", "rtl"] as const) {
.forEach((animation) => animation.finish()),
)
}
await expect(panel).toBeHidden()
})
}
@@ -764,6 +838,7 @@ 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)
}
@@ -781,18 +856,22 @@ 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,
@@ -804,8 +883,11 @@ 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,7 +10,8 @@ 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 `summary.toggle`.
// The legacy command ID; the keybind migration renames it to `details.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 })
@@ -25,6 +26,7 @@ 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
@@ -41,6 +43,18 @@ 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" })
})
}
@@ -74,7 +88,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.summary.prefs") ?? "null")),
page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.details.prefs") ?? "null")),
)
.toEqual({ projectExpanded: true, serverExpanded: false })
@@ -120,6 +134,49 @@ 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 })
@@ -131,9 +188,11 @@ 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()
@@ -145,6 +204,7 @@ 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)
@@ -152,6 +212,7 @@ 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)
@@ -160,10 +221,12 @@ 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")]
}),
]) {
@@ -171,10 +234,12 @@ 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 })
@@ -188,6 +253,7 @@ 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)
@@ -209,6 +275,7 @@ 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")
@@ -224,6 +291,7 @@ 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",
@@ -236,6 +304,7 @@ 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: () =>
@@ -280,7 +349,9 @@ 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()
},
)
@@ -337,6 +408,7 @@ 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" },
@@ -347,6 +419,7 @@ 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([])
})
@@ -363,6 +436,7 @@ 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]") })
})
@@ -371,9 +445,11 @@ 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")
@@ -386,14 +462,20 @@ 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({
@@ -411,6 +493,7 @@ 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: [] }],
@@ -420,16 +503,19 @@ 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()
@@ -440,6 +526,7 @@ 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)
@@ -475,6 +562,7 @@ 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: [] } },
@@ -483,6 +571,7 @@ 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,
@@ -512,15 +601,18 @@ 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")
@@ -533,9 +625,11 @@ 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()
@@ -562,6 +656,7 @@ 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)
@@ -579,6 +674,7 @@ function ownerWarnings(page: Page) {
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
return warnings
}
@@ -589,6 +685,7 @@ 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,6 +30,7 @@ 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")
@@ -64,10 +65,33 @@ 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 }),
@@ -75,11 +99,13 @@ 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 })}`,
@@ -107,6 +133,7 @@ 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 }),
@@ -114,6 +141,7 @@ 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(
@@ -126,11 +154,13 @@ 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" })
@@ -155,6 +185,7 @@ 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 }),
@@ -162,6 +193,7 @@ 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(
@@ -199,14 +231,17 @@ 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 }),
@@ -214,6 +249,7 @@ 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]: {
@@ -222,6 +258,7 @@ 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 })}`,
+73 -1
View File
@@ -13,11 +13,13 @@ import {
} from "../utils/app"
import { mockServers } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockWorkspace } from "../utils/workspace"
import { fileNode, mockWorkspace, openSession } 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" })
@@ -28,6 +30,7 @@ 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]")
@@ -36,6 +39,7 @@ 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)
}),
)
@@ -43,6 +47,7 @@ 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()
@@ -118,6 +123,7 @@ 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(
@@ -156,11 +162,13 @@ 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()
@@ -168,6 +176,7 @@ 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()
@@ -242,8 +251,11 @@ 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" }
@@ -267,6 +279,7 @@ 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) =>
@@ -274,6 +287,7 @@ 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(
@@ -292,10 +306,12 @@ 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`,
@@ -307,12 +323,14 @@ 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) => ({
@@ -329,6 +347,7 @@ 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 }] },
@@ -342,6 +361,7 @@ 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([
{
@@ -361,6 +381,56 @@ 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: [] })
@@ -384,6 +454,7 @@ async function twoServers(
) {
const config = (sessions: ReturnType<typeof session>[], id: string) => {
const directory = String(sessions[0]!.directory)
return {
directory,
project: project({ id, directory }),
@@ -393,6 +464,7 @@ async function twoServers(
strictDirectory: true,
}
}
await mockServers(page, {
[SERVER]: config(input.a, "proj_server_a"),
[REMOTE_SERVER]: {
@@ -0,0 +1,211 @@
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,28 +1,95 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { AppBaseProviders, AppInterface, preloadRoute } 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"
export function mount(input: { server: string; route: string; direction: Direction }) {
/**
* 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 }) {
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>
)
}
@@ -30,7 +97,7 @@ export function mount(input: { server: string; route: string; direction: Directi
() => (
<PlatformProvider value={createWebPlatform("test").platform}>
<AppBaseProviders locale="en">
<DirectedApp />
<Startup />
</AppBaseProviders>
</PlatformProvider>
),
+6 -1
View File
@@ -14,7 +14,12 @@
<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") })
mount({
server: query.get("server"),
route: query.get("route"),
direction: query.get("direction"),
held: query.has("held"),
})
</script>
</body>
</html>
+23 -1
View File
@@ -1,6 +1,28 @@
import type { Page } from "@playwright/test"
import { expect, 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"),
}
}
+103 -52
View File
@@ -10,6 +10,12 @@ 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
@@ -25,23 +31,28 @@ 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[]
@@ -77,12 +88,13 @@ export function mount(input: {
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? ready()
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
runtime: (
{
ready: ready(),
failed: { kind: "failed", message: "WSL failed to start" },
stopped: { kind: "stopped" },
} satisfies Record<typeof input.mode, WslServerRuntime>
)[input.mode],
},
],
opencodeChecks: {
@@ -97,76 +109,93 @@ 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 = (remote: string) => structuredClone(unwrap(remote === "ssh" ? store.ssh : store.state))
const publish = (remote: string) =>
listeners.forEach((listener) => listener({ type: "state", remote, state: snapshot(remote) }))
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) }))
// 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 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) }))
},
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())
},
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
},
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) }))
},
} 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[request.remote]?.[request.method]
const method = methods.get(request.ipc)?.[request.method]
if (!method) throw new Error("Unexpected fixture action")
const result = method(request.input as { id?: string; name?: string })
publish(request.remote)
// 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)
return result ?? null
},
async subscribe(remote) {
if (remote === "wsl" || remote === "ssh") return { available: true, state: snapshot(remote) }
async subscribe(ipc) {
if (ipc === "wsl" || ipc === "ssh") return { available: true, state: snapshot(ipc) }
return { available: false }
},
on(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
surface: () => undefined,
embed: () => undefined,
capture: async () => undefined,
menubar: () => undefined,
runMenubarItem: () => undefined,
configure: () => undefined,
manager: {
list: async () => [],
@@ -179,9 +208,11 @@ export function mount(input: {
asset: () => "",
},
}
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const platform: Platform = {
platform: "desktop",
os: "windows",
@@ -191,14 +222,18 @@ 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
@@ -209,6 +244,7 @@ export function mount(input: {
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
@@ -221,12 +257,27 @@ export function mount(input: {
onChange={(event) => {
const available = event.currentTarget.checked
setStore("available", available)
listeners.forEach((listener) => listener({ type: "available", remote: "wsl", available }))
listeners.forEach((listener) => listener({ type: "available", ipc: "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,21 +9,25 @@ 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: [] as string[] })
const [store, setStore] = createStore<{ actions: string[] }>({ actions: [] })
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,
surface: () => undefined,
embed: () => undefined,
capture: async () => undefined,
menubar: () => undefined,
runMenubarItem: () => undefined,
configure: () => undefined,
manager: {
list: async () => [],
@@ -36,6 +40,7 @@ export function mount(input: { server: string }) {
asset: () => "",
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
@@ -47,6 +52,7 @@ export function mount(input: { server: string }) {
extensions: bridge,
runDesktopMenuAction: (action) => setStore("actions", (actions) => [...actions, action]),
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
+4 -5
View File
@@ -11,7 +11,6 @@ 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"
@@ -32,6 +31,7 @@ function QueryProvider(props: ParentProps) {
},
},
})
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
@@ -65,6 +65,7 @@ export function AppBaseProviders(
<ErrorBoundary
fallback={(error) => {
void import("@sentry/solid").then(({ captureException }) => captureException(error))
return <ErrorPage error={error} />
}}
>
@@ -99,10 +100,8 @@ export function AppInterface(props: {
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
{props.children}
{rootProps.children}
</CommandProvider>
</GlobalProvider>
</TabsProvider>
+109 -23
View File
@@ -31,11 +31,13 @@ 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"
@@ -52,11 +54,17 @@ 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 = {
@@ -83,6 +91,7 @@ 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({
@@ -90,6 +99,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
@@ -98,11 +108,14 @@ 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),
@@ -111,14 +124,31 @@ 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
@@ -128,6 +158,7 @@ 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 = ""
}}
@@ -138,17 +169,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
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
}
search={search()}
onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
/>
@@ -162,6 +183,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onSubmit={(event) => {
event.preventDefault()
if (!props.disabled) props.controller.submit()
}}
onDragEnter={props.controller.onDragEnter}
@@ -224,12 +246,16 @@ 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)
}
@@ -241,12 +267,17 @@ 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
})
}}
@@ -375,6 +406,7 @@ 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)
@@ -384,19 +416,26 @@ 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()
@@ -417,85 +456,111 @@ 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") {
parts.push({
...(original?.type === "agent" ? original : {}),
const agent: ComposerAgentPart = {
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") {
parts.push({
...(original?.type === "skill" ? original : {}),
const skill: ComposerSkillPart = {
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
}
parts.push({
...(original?.type === "file" ? original : {}),
const parsed: ComposerFilePart = {
type: "file",
path: element.dataset.path ?? content.slice(1),
content,
start: position,
end: position + content.length,
...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
})
}
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)
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
}
@@ -513,6 +578,7 @@ 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">
@@ -570,6 +636,7 @@ 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. */}
@@ -703,6 +770,7 @@ function ComposerEditorConfiguredSelect(props: {
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<ComposerEditorSelect
title={props.title}
@@ -790,13 +858,17 @@ 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))
@@ -862,7 +934,9 @@ 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) *
@@ -878,14 +952,20 @@ 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) => (
@@ -941,10 +1021,13 @@ export function ComposerEditorSubmitButton(props: {
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (props.stopping) {
props.onStop()
return
}
props.onSubmit()
}}
/>
@@ -954,8 +1037,11 @@ 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" }}
+75 -18
View File
@@ -1,8 +1,9 @@
import { ImagePreview } from "@opencode/ui/image-preview"
import { useDialog } from "@opencode/ui/context/dialog"
import type { ReferenceInfo } from "@opencode/client/promise"
import type { Links, SessionView } from "@opencode/gui-extensions/sdk"
import type { Links, MountedSession } 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"
@@ -10,7 +11,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/services"
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
@@ -30,12 +31,20 @@ 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()
@@ -60,43 +69,59 @@ 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,
@@ -110,6 +135,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
@@ -137,6 +163,7 @@ 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)
@@ -157,7 +184,9 @@ 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) => ({
@@ -192,6 +221,7 @@ 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}`,
@@ -211,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
@@ -222,7 +253,9 @@ 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,
@@ -236,7 +269,9 @@ 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: {
@@ -247,13 +282,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
failed: (kind, error) =>
showToast({
title: language.t(
kind === "shell"
? "prompt.toast.shellSendFailed.title"
: kind === "command"
? "prompt.toast.commandSendFailed.title"
: "prompt.toast.promptSendFailed.title",
),
title: language.t(sendFailedTitle[kind]),
description:
kind === "command"
? formatServerError(error, language.t, language.t("common.requestFailed"))
@@ -267,10 +296,13 @@ 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,
@@ -278,6 +310,7 @@ 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,
@@ -301,23 +334,32 @@ 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: {
@@ -347,6 +389,7 @@ 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 })),
@@ -370,20 +413,25 @@ 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", () => [
@@ -414,42 +462,51 @@ 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>, 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
}
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
return language.t("common.requestFailed")
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
links: Links,
session: SessionView | undefined,
session: MountedSession | 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,7 +35,6 @@ 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"
@@ -0,0 +1,15 @@
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
@@ -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 { SurfaceProps, Surfaces } from "@opencode/gui-extensions/sdk"
import type { EmbedProps, Embeds } from "@opencode/gui-extensions/sdk"
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
const geometry =
@@ -14,56 +14,65 @@ type Input = {
readonly dialog: () => boolean
}
/** Native main-process surfaces laid out over the DOM box an extension renders for them. */
export function createSurfaces(input: Input): Surfaces {
/** Main-process embeds laid out over the DOM box an extension renders for them. */
export function createEmbeds(input: Input): Embeds {
return {
View: (props) => {
const bridge = input.bridge
// Without the desktop bridge there is no surface; the box still renders its children.
// Without the desktop bridge there is no embed; the box still renders its children.
if (!bridge) return <div class={props.class}>{props.children}</div>
return <SurfaceView {...props} input={input} bridge={bridge} />
return <EmbedView {...props} input={input} bridge={bridge} />
},
capture: (id) => input.bridge?.capture(id) ?? Promise.resolve(undefined),
}
}
function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
const [store, setStore] = createStore({
function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
const [store, setStore] = createStore<{ visible: boolean; snapshot: { id: string; url: string } | undefined }>({
visible: typeof document === "undefined" || document.visibilityState === "visible",
// 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,
// A still of the embed shown in the DOM while floating content covers the hidden native view.
snapshot: 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 surface the last layout went to; it hides when another one takes the box.
// The embed 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.surface(placed)
if (placed) props.bridge.embed(placed)
placed = undefined
}
// The native surface always paints above the DOM, so hide it while a floating
// The native embed 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 surface on screen as a still under the floating content. The native view
// Keep the embed 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
@@ -71,23 +80,29 @@ function SurfaceView(props: SurfaceProps & { 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 surface; the box shows its background as before.
// A failed capture still hides the embed; 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(() => {
@@ -95,9 +110,11 @@ function SurfaceView(props: SurfaceProps & { 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()
@@ -107,16 +124,22 @@ function SurfaceView(props: SurfaceProps & { 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 surface inside it.
// The cutout exposes the app backdrop outside the rounded card, not the embed 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) {
@@ -124,23 +147,31 @@ function SurfaceView(props: SurfaceProps & { 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
props.bridge.surface(id, {
const box = {
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)
}
@@ -156,15 +187,16 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
],
() => {
layout = undefined
// 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.
// 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.
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 pane drag.
// animation frame keeps the native view in step with a region 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,827 @@
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,58 +1,62 @@
import { expect, test } from "bun:test"
import type { Remote } from "@opencode/gui-extensions/sdk"
import type { Ipc } from "@opencode/gui-extensions/sdk"
import type { Bridge, BridgeMessage } from "@opencode/gui-extensions/sdk/bridge"
import { createRemotes } from "./remote"
import { createIpcClients } from "./ipc"
const token: Ipc = { kind: "ipc", id: "fixture", spec: { id: "fixture", methods: {} } }
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 remote appearing",
{ type: "available", remote: "fixture", available: true },
"the Ipc appearing",
{ type: "available", ipc: "fixture", available: true },
{ available: false },
{ available: true, state: undefined },
],
[
"the remote going away",
{ type: "available", remote: "fixture", available: false },
"the Ipc going away",
{ type: "available", ipc: "fixture", available: false },
{ available: true, state: { count: 1 } },
{ available: false, state: undefined },
],
[
"a state push",
{ type: "state", remote: "fixture", state: { count: 2 } },
{ type: "state", ipc: "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 remotes = createRemotes(bridge.value)
expect(remotes.client(token)).toBeUndefined()
const ipcs = createIpcClients(bridge.value)
expect(ipcs.client(token)).toBeUndefined()
bridge.emit(event)
bridge.reply(reply)
await Bun.sleep(0)
const client = remotes.client(token)
const client = ipcs.client(token)
expect({ available: !!client, state: client?.state() }).toEqual(expected)
remotes.dispose()
ipcs.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)
}
},
surface: () => {},
embed: () => {},
capture: async () => undefined,
menubar: () => {},
runMenubarItem: () => {},
configure: () => {},
manager: {
list: async () => [],
@@ -65,6 +69,7 @@ function fakeBridge() {
asset: () => "",
},
}
return {
value,
emit: (message: BridgeMessage) => listeners.forEach((listener) => listener(message)),
+179
View File
@@ -0,0 +1,179 @@
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?.()
},
}
}
@@ -0,0 +1,28 @@
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 }])
},
}
}
@@ -0,0 +1,28 @@
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)
}
@@ -1,26 +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"
/** 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)
}
+29 -7
View File
@@ -4,13 +4,21 @@ 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 SessionView } from "@opencode/gui-extensions/sdk"
import {
DrawerContext,
Panel,
type PanelSidebar,
type PanelTab,
type MountedSession,
} from "@opencode/gui-extensions/sdk"
import { useLanguage } from "@/runtime/i18n/language"
import { useExtensionHost } from "./host"
import { MobilePanel, panelKey, type Region, type RegionEntry } from "./panels"
import { panelKey } from "./panel-keys"
import { MobilePanel, type Region, type RegionEntry } from "./panels"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
@@ -19,20 +27,25 @@ 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"])),
@@ -48,21 +61,30 @@ export function MobileViewTabs(props: {
views: MobileViews
region: Region
current: string
session: SessionView
session: MountedSession
sidebar: PanelSidebar
onSelect: (key: string) => void
}): JSX.Element {
const language = useLanguage()
const [store, setStore] = createStore({
const [store, setStore] = createStore<{
menu: boolean
drawer: string | undefined
last: string | undefined
loaded: boolean
pending: string | undefined
}>({
menu: false,
drawer: undefined as string | undefined,
drawer: undefined,
// Keeps the last drawer's content mounted while it animates closed.
last: undefined as string | undefined,
last: undefined,
loaded: false,
pending: undefined as string | undefined,
pending: 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-['']"
@@ -0,0 +1,255 @@
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) }
}
@@ -0,0 +1,15 @@
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 { PanelTab } from "@opencode/gui-extensions/sdk"
import type { MountedSession, PanelTab } from "@opencode/gui-extensions/sdk"
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { Contribution } from "./render"
@@ -16,6 +16,7 @@ export function PanelTrigger(props: {
value: string
extension: string
tab: PanelTab
session: MountedSession
index: number
active: boolean
preview: boolean
@@ -25,7 +26,9 @@ 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 selection never remounts it.
// The label renders once per label function; state is read through getters so neither selection nor a session
// switch remounts it.
const state = {
get active() {
return props.active
@@ -33,14 +36,23 @@ 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={
@@ -57,6 +69,7 @@ export function PanelTrigger(props: {
{button}
</Tooltip>
)
const closeButton = (reveal: boolean) =>
tooltip(
<IconButton
@@ -77,9 +90,24 @@ 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={closeButton(false)} />}>
<Match when={props.tab.kind === "pinned"}>
<Switch fallback={<SortableTrigger {...props} content={content()} close={close()} />}>
<Match when={props.tab.pinned}>
<Tabs.Trigger
value={props.value}
id={props.tab.dom?.tab}
@@ -88,26 +116,24 @@ export function PanelTrigger(props: {
{content()}
</Tabs.Trigger>
</Match>
<Match when={props.tab.kind === "fixed"}>
<Match when={props.tab.draggable === false && props.tab.closable === "compact"}>
<Tabs.Trigger
value={props.value}
id={props.tab.dom?.tab}
onMiddleClick={() => props.onClose(props.value)}
closeButton={tooltip(
<Tabs.CloseButton onClick={() => props.onClose(props.value)} aria-label={language.t("common.closeTab")} />,
)}
closeButton={compactClose()}
hideCloseButton
>
{content()}
</Tabs.Trigger>
</Match>
<Match when={props.tab.kind === "launcher"}>
<Match when={props.tab.draggable === false}>
<Tabs.Trigger
value={props.value}
id={props.tab.dom?.tab}
class="group"
onMiddleClick={() => props.onClose(props.value)}
closeButton={closeButton(true)}
onMiddleClick={props.tab.closable === false ? undefined : () => props.onClose(props.value)}
closeButton={close()}
hideCloseButton
>
{content()}
@@ -124,7 +150,7 @@ function SortableTrigger(props: {
active: boolean
preview: boolean
content: JSX.Element
close: JSX.Element
close: JSX.Element | undefined
onClose: (value: string) => void
onPromote: (value: string) => void
}): JSX.Element {
@@ -136,6 +162,7 @@ function SortableTrigger(props: {
return props.index
},
})
return (
<div ref={sortable.ref} class="h-full flex items-center">
<div class="relative">
@@ -144,7 +171,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.onClose(props.value)}
onMiddleClick={props.tab.closable === false ? undefined : () => props.onClose(props.value)}
onDblClick={() => {
if (props.preview) props.onPromote(props.value)
}}
+121 -39
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, on, onMount, Show, type Accessor, type JSX } from "solid-js"
import { createEffect, createMemo, For, on, onCleanup, 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 SessionView,
type MountedSession,
} from "@opencode/gui-extensions/sdk"
import { same } from "@/runtime/persistence/equality"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -24,6 +24,8 @@ 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[]
@@ -41,10 +43,6 @@ 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(
@@ -53,14 +51,18 @@ const SidebarState = Persistence.struct({
expandMode: Schema.Literals(["expand", "collapse"]),
})
/** The inner sidebar preference every side panel shares. The key predates extensions. */
/**
* 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.
*/
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",
})
return {
const sidebar: PanelSidebar = {
opened: () => store.sidebarOpened,
width: () => store.sidebarWidth,
transition: ready,
@@ -71,18 +73,27 @@ 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. */
export function createRegion(input: { region: Panel["region"]; view: SessionView; tabs: Tabs }) {
/**
* 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 }) {
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,
@@ -92,25 +103,29 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
)
}),
)
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") 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") 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.
createEffect(() => {
const legacy = new Map(
providers().flatMap((item) =>
Object.entries(item.value.legacy ?? {}).map(([key, id]) => [key, panelKey(item.extension, id)] as const),
),
)
const legacy = legacyKeys(providers())
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)
})
@@ -119,30 +134,36 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
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.kind !== "pinned" ? [entry] : []
return entry && !entry.tab.pinned ? [entry] : []
})
return [
...entries().filter((entry) => entry.tab.kind === "pinned"),
...entries().filter((entry) => entry.tab.pinned),
...listed.filter((entry) => entry.tab.first),
...listed.filter((entry) => !entry.tab.first),
]
})
// Narrow screens never select a launcher: a stored one falls back like a missing tab.
// Narrow screens never select a transient tab: 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.kind !== "launcher")))
return value
if (value && strip().some((entry) => entry.key === value && (desktop() || !entry.tab.transient))) return value
return strip()
.filter((entry) => entry.tab.fallback !== undefined)
.reduce<RegionEntry | undefined>(
@@ -155,7 +176,9 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
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
}),
)
@@ -171,20 +194,22 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
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.kind !== "pinned")?.tab.first,
lead: () => !!drawn().find((entry) => !entry.tab.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())
},
}
}
@@ -194,31 +219,39 @@ 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: SessionView
view: MountedSession
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) => (
@@ -239,7 +272,16 @@ export function RegionContent(props: {
}}
>
<Contribution extension={extension}>
{() => member()!.provider.render(() => member()!.tab, props.view)}
{() =>
member()!.provider.render({
get tab() {
return member()!.tab
},
get session() {
return props.view
},
})
}
</Contribution>
</PanelContext.Provider>
</div>
@@ -251,6 +293,7 @@ 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) => (
@@ -266,7 +309,16 @@ export function RegionContent(props: {
value={{ ...props.frame, visible: props.frame.shown, open: () => props.region.openFor(extension) }}
>
<Contribution extension={extension}>
{() => entry()!.provider.render(() => entry()!.tab, props.view)}
{() =>
entry()!.provider.render({
get tab() {
return entry()!.tab
},
get session() {
return props.view
},
})
}
</Contribution>
</PanelContext.Provider>
</div>
@@ -285,7 +337,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: SessionView
view: MountedSession
sidebar: PanelSidebar
stacked?: boolean
fill?: boolean
@@ -300,9 +352,11 @@ 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)
@@ -319,20 +373,27 @@ 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 pane = () => Math.min(height(), max())
const regionHeight = () => Math.min(height(), max())
const stacked = createMemo(() => isDesktop() && !!props.stacked)
const panelHeight = createMemo(() => {
if (props.fill) return "100%"
if (!opened()) return "0px"
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
return `${pane()}px`
if (isDesktop()) return stacked() ? `${regionHeight()}px` : "100%"
return `${regionHeight()}px`
})
const contentHeight = createMemo(
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${regionHeight()}px` : "100%") : `${regionHeight()}px`),
)
const present = createMemo(() => opened() || !!props.present)
let root: HTMLElement | undefined
@@ -340,13 +401,16 @@ 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()
})
@@ -376,7 +440,7 @@ export function DockRegion(props: {
<ResizeHandle
class="-top-1"
direction="vertical"
size={pane()}
size={regionHeight()}
min={100}
max={max()}
collapseThreshold={50}
@@ -410,7 +474,16 @@ export function DockRegion(props: {
}}
>
<Contribution extension={extension}>
{() => entry()!.provider.render(() => entry()!.tab, props.view)}
{() =>
entry()!.provider.render({
get tab() {
return entry()!.tab
},
get session() {
return props.view
},
})
}
</Contribution>
</PanelContext.Provider>
)}
@@ -423,7 +496,7 @@ export function DockRegion(props: {
/** Renders one panel as a narrow-screen view. */
export function MobilePanel(props: {
entry: RegionEntry
view: SessionView
view: MountedSession
sidebar: PanelSidebar
visible: boolean
open: Accessor<readonly string[]>
@@ -441,7 +514,16 @@ export function MobilePanel(props: {
}}
>
<Contribution extension={props.entry.extension}>
{() => props.entry.provider.render(() => props.entry.tab, props.view)}
{() =>
props.entry.provider.render({
get tab() {
return props.entry.tab
},
get session() {
return props.view
},
})
}
</Contribution>
</PanelContext.Provider>
)
@@ -1,119 +0,0 @@
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?.()
},
}
}
+19 -6
View File
@@ -1,18 +1,24 @@
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 SessionView, type SlotMap } from "@opencode/gui-extensions/sdk"
import { ExtensionContext, Slot, Style, type MountedSession, type SlotMap } from "@opencode/gui-extensions/sdk"
import { useExtensionHost } from "./host"
/** Renders one extension contribution with its context and error isolation. */
/**
* 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.
*/
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))
onMount(() => host.fail(props.extension, error, "render"))
return null
}}
>
@@ -26,17 +32,22 @@ 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}>
{() => (item.value.render as (input: SlotMap[At]) => JSX.Element)(props.input)}
{() =>
// 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)
}
</Contribution>
)}
</For>
@@ -45,6 +56,7 @@ 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>
@@ -52,10 +64,11 @@ export function ExtensionStyles() {
)
}
/** Routes local markdown links in the session to extension Link handlers, keeping image loading. */
export function ExtensionLinks(props: ParentProps<{ session: SessionView }>) {
/** Routes local markdown links in the session to extension LinkHandler contributions, keeping image loading. */
export function ExtensionLinks(props: ParentProps<{ session: MountedSession }>) {
const host = useExtensionHost()
const markdown = useMarkdown()
return (
<MarkdownProvider
readImage={markdown?.readImage}
+52 -31
View File
@@ -1,54 +1,65 @@
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 { createMenubar, ExtensionMenubarProvider } from "./menubar"
import { createMenubarItems, MenubarItemsProvider } from "./menubar-items"
import { usePlatform } from "@/runtime/platform/platform"
import { ExtensionHostProvider, useExtensionHost } from "./host"
import { createRemotes } from "./remote"
import {
createExtensionAttachment,
createExtensionServices,
ExtensionAttachmentProvider,
type ExtensionServices,
} from "./services"
import { createIpcClients } from "./ipc"
import { createExtensionAttachment, createHostApis, ExtensionAttachmentProvider, type HostApis } from "./host-apis"
import { ExtensionCommands } from "./commands"
import { ExtensionStyles } from "./render"
import { ExtensionServersProvider } from "./servers"
import { ExtensionServerEndpoints } from "./server-shell"
import { createContext, useContext } from "solid-js"
const ServicesContext = createContext<ExtensionServices>()
const HostApisContext = createContext<HostApis>()
const ExtensionHotReload = import.meta.env.DEV ? lazy(() => import("./hmr")) : undefined
export function useExtensionServices() {
const value = useContext(ServicesContext)
if (!value) throw new Error("Extension services are unavailable")
export function useHostApis() {
const value = useContext(HostApisContext)
if (!value) throw new Error("Host APIs 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 services = createExtensionServices()
const apis = createHostApis()
const bridge = platform.extensions
const menubar = createMenubar(bridge)
const remotes = createRemotes(bridge)
onCleanup(remotes.dispose)
const menubar = createMenubarItems(bridge)
const ipcs = createIpcClients(bridge)
onCleanup(ipcs.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.os || (!!os && definition.os.includes(os)))
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)
return (
<ServicesContext.Provider value={services}>
<HostApisContext.Provider value={apis}>
<ExtensionHostProvider
definitions={definitions}
disabled={disabled}
services={services.services}
remote={(token) => remotes.client(token)}
apis={apis.apis}
whenMounted={apis.whenMounted}
ipc={bridge ? (token) => ipcs.client(token) : undefined}
generation={ipcs.generation}
failed={failed}
>
<ExtensionStyles />
{ExtensionHotReload && (
@@ -56,27 +67,37 @@ export function ExtensionRoot(props: ParentProps) {
<ExtensionHotReload />
</Suspense>
)}
<ExtensionMenubarProvider value={menubar}>
<ExtensionServersProvider
failed={(id) => installed.list().some((item) => item.id === id && item.error !== undefined)}
>
{props.children}
</ExtensionServersProvider>
</ExtensionMenubarProvider>
<MenubarItemsProvider value={menubar}>
<ExtensionServersProvider failed={failed}>{props.children}</ExtensionServersProvider>
</MenubarItemsProvider>
</ExtensionHostProvider>
</ServicesContext.Provider>
</HostApisContext.Provider>
)
}
/** Attaches session and layout services and publishes extension commands. Renders once extensions are active. */
/** Attaches the session and layout HostApis and publishes extension commands. Renders once extensions are active. */
export function ExtensionAttachment(props: ParentProps) {
const host = useExtensionHost()
const attachment = createExtensionAttachment(useExtensionServices())
const attachment = createExtensionAttachment(useHostApis())
return (
<ExtensionAttachmentProvider value={attachment}>
<ExtensionCommands />
<ExtensionServerEndpoints />
<Show when={host.ready()}>{props.children}</Show>
<Show when={host.ready()}>
<MountedInterface attach={attachment.attach} />
{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
}
+29 -11
View File
@@ -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 { Menu, Server, type ServerEntry } from "@opencode/gui-extensions/sdk"
import { MenuItem, 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,7 +35,9 @@ const ServersContext = createContext<ReturnType<typeof createExtensionServers>>(
export function useExtensionServers() {
const value = useContext(ServersContext)
if (!value) throw new Error("Extension servers are unavailable")
return value
}
@@ -55,6 +57,7 @@ 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,
@@ -65,13 +68,16 @@ 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]) => ({
@@ -87,29 +93,37 @@ 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) =>
@@ -117,6 +131,7 @@ 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. */
@@ -133,6 +148,7 @@ 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,
@@ -161,33 +177,35 @@ 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 }
}
/** Menu "server.row" items that apply to a server. */
/** MenuItem "server.row" contributions that apply to a server. */
export function useServerRowItems(server: () => string) {
const host = useExtensionHost()
return createMemo(() =>
host
.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),
.list(MenuItem)
.flatMap((item) => (item.menu === "server.row" && (item.when?.(server()) ?? true) ? [item] : []))
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
)
}
/** Menu "server.add" items, in order. */
/** MenuItem "server.add" contributions, in order. */
export function useServerAddItems() {
const host = useExtensionHost()
return createMemo(() =>
host
.items(Menu)
.filter((item) => item.value.menu === "server.add")
.toSorted((a, b) => (a.value.order ?? 0) - (b.value.order ?? 0))
.map((item) => item.value),
.list(MenuItem)
.flatMap((item) => (item.menu === "server.add" ? [item] : []))
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
)
}
@@ -1,677 +0,0 @@
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)
},
}
}
@@ -14,7 +14,8 @@ export function GuiExtensionsSettings() {
const language = useLanguage()
const host = useExtensionHost()
const bridge = usePlatform().extensions
const [installed, setInstalled] = createStore({ list: [] as Installed[] })
const [installed, setInstalled] = createStore<{ list: Installed[] }>({ list: [] })
if (bridge) {
void bridge.manager.list().then((list) => setInstalled("list", reconcile([...list])))
onCleanup(
@@ -23,15 +24,23 @@ 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)
@@ -60,7 +69,7 @@ export function GuiExtensionsSettings() {
description={
<>
{status(definition.id)}
<Show when={host.state.errors[definition.id]}>
<Show when={host.state.failures[definition.id]?.error}>
{(error) => <pre class="mt-1 whitespace-pre-wrap text-text-danger-base">{error()}</pre>}
</Show>
</>
@@ -3,9 +3,10 @@ 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 Setting id. */
export function ExtensionSettingPages() {
/** One settings panel per extension page; the tab value is the SettingsPage id. */
export function ExtensionSettingsPages() {
const surface = useSettingsSurface()
return (
<For each={surface.extensions.pages()}>
{(item) => (
@@ -15,6 +16,7 @@ export function ExtensionSettingPages() {
item.value.render({
get target() {
const view = surface.view()
return view.tab === item.value.id ? view.target : undefined
},
})
@@ -26,11 +28,15 @@ export function ExtensionSettingPages() {
)
}
/** Extension sections on a host settings page, in contribution order. */
export function ExtensionSettingSections(props: { page: "general" | "servers" }) {
/**
* 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" }) {
const surface = useSettingsSurface()
return (
<For each={surface.extensions.sections(props.page)}>
<For each={surface.extensions.sections(props.page, props.section)}>
{(item) => (
<Contribution extension={item.extension}>
{() =>
@@ -1,31 +1,38 @@
import { createMemo } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Setting } from "@opencode/gui-extensions/sdk"
import { SettingsPage } from "@opencode/gui-extensions/sdk"
import { usePlatform } from "@/runtime/platform/platform"
import { useExtensionHost } from "./host"
export type SettingItem = ReturnType<ReturnType<typeof createExtensionSettings>["items"]>[number]
export type SettingsPageItem = ReturnType<ReturnType<typeof createSettingsPages>["items"]>[number]
/** Extension settings that apply to this platform and viewport. */
export function createExtensionSettings() {
/** Extension settings pages that apply to this platform and viewport. */
export function createSettingsPages() {
const host = useExtensionHost()
const platform = usePlatform()
const mobile = createMediaQuery("(max-width: 767px)")
const items = createMemo(() =>
host.items(Setting).filter((item) => {
host.items(SettingsPage).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 without a host page, in contribution order. */
/** Settings pages without a host page, in contribution order. */
pages,
/** Their tab values. */
tabs,
sections: (page: "general" | "servers") => items().filter((item) => item.value.page === page),
/** 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),
}
}
@@ -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 {
Menu as MenuPoint,
type Menu as MenuItem,
MenuItem,
type MountedSession,
type PanelSidebar,
type SessionView,
type SessionPanelMenuItem,
} 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: SessionView
view: MountedSession
region: Region
sidebar: PanelSidebar
fileTree: boolean
@@ -56,16 +56,21 @@ 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(MenuPoint)
.filter((item) => item.menu === "session.panel")
.list(MenuItem)
.flatMap((item) => (item.menu === "session.panel" ? [item] : []))
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
)
@@ -120,6 +125,7 @@ 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)
}}
@@ -167,6 +173,7 @@ 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}
@@ -193,7 +200,14 @@ export function SideRegion(props: {
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ExtensionSlot at="session.panel.end" input={{ session: props.view }} />
<ExtensionSlot
at="session.panel.end"
input={{
get session() {
return props.view
},
}}
/>
<Show when={tabsVisible()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
@@ -244,7 +258,14 @@ 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={{ session: props.view }} />
<ExtensionSlot
at="session.panel.sidebar"
input={{
get session() {
return props.view
},
}}
/>
</div>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
@@ -268,9 +289,10 @@ export function SideRegion(props: {
)
}
function AddButton(props: { item: MenuItem }): JSX.Element {
function AddButton(props: { item: SessionPanelMenuItem }): JSX.Element {
const command = useCommand()
const keybind = createMemo(() => (props.item.keybind ? command.keybindParts(props.item.keybind) : []))
return (
<Tooltip
value={
@@ -288,16 +310,17 @@ function AddButton(props: { item: MenuItem }): 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 MenuItem[] }): JSX.Element {
function AddMenu(props: { items: readonly SessionPanelMenuItem[] }): 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}>
@@ -316,10 +339,11 @@ function AddMenu(props: { items: readonly MenuItem[] }): 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" />
@@ -0,0 +1,245 @@
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()
},
}
}
@@ -1,35 +1,40 @@
import { createMemo, For, Show } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { Status } from "@opencode/gui-extensions/sdk"
import { TitlebarItem } from "@opencode/gui-extensions/sdk"
import { useExtensionHost } from "./host"
/** Status items placed in the titlebar (the default placement). */
export function useTitlebarStatus() {
/** TitlebarItem contributions placed in the titlebar (the default placement). */
export function useTitlebarItems() {
const host = useExtensionHost()
const items = createMemo(() => host.list(Status).filter((item) => (item.placement ?? "titlebar") === "titlebar"))
const items = createMemo(() =>
host.list(TitlebarItem).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 TitlebarStatusItems(props: { status: ReturnType<typeof useTitlebarStatus>; vertical?: boolean }) {
export function TitlebarItems(props: { items: ReturnType<typeof useTitlebarItems>; vertical?: boolean }) {
return (
<For each={props.status.ids()}>
<For each={props.items.ids()}>
{(id) => (
<Show when={props.status.item(id)}>
{(item) => <TitlebarStatusButton item={item()} vertical={props.vertical} />}
<Show when={props.items.item(id)}>
{(item) => <TitlebarItemButton item={item()} vertical={props.vertical} />}
</Show>
)}
</For>
)
}
function TitlebarStatusButton(props: { item: Status; vertical?: boolean }) {
function TitlebarItemButton(props: { item: TitlebarItem; 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"
@@ -41,6 +46,7 @@ function TitlebarStatusButton(props: { item: Status; vertical?: boolean }) {
{props.item.label}
</span>
)
return (
<div
data-slot="titlebar-update"
@@ -54,7 +60,7 @@ function TitlebarStatusButton(props: { item: Status; 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}
-181
View File
@@ -1,181 +0,0 @@
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) }
}
-6
View File
@@ -295,10 +295,6 @@ 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": "በራስ-መቀበል ፍቃዶችን አቁሟል",
@@ -701,7 +697,6 @@ export const dict = {
"session.view.select": "የክፍለ ጊዜ እይታ",
"session.background.moveRunning": "ወደ ዳራ አንቀሳቅስ",
"session.timeline.working": "በመስራት ላይ",
"session.review.wrapLines": "መስመሮችን አጠፍ",
"session.websearch.title": "የሶስተኛ ወገን ድር ፍለጋ",
"session.websearch.description": "ወኪሎች ድሩን ለመፈለግ የሚጠቀሙበትን የፍለጋ አቅራቢ ይምረጡ",
"session.websearch.provider": "የፍለጋ አቅራቢ",
@@ -759,7 +754,6 @@ 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}}",
-6
View File
@@ -577,10 +577,6 @@ 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}} إضافي)",
@@ -761,7 +757,6 @@ 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": "موفر البحث",
@@ -825,7 +820,6 @@ 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",
-6
View File
@@ -298,10 +298,6 @@ 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",
@@ -721,7 +717,6 @@ 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",
@@ -783,7 +778,6 @@ 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}}",
-7
View File
@@ -297,10 +297,6 @@ 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": "Автоматичното приемане на разрешения е спряно",
@@ -720,7 +716,6 @@ export const dict = {
"session.view.select": "Изглед на сесията",
"session.background.moveRunning": "Преместване на заден план",
"session.timeline.working": "Работи",
"session.review.wrapLines": "Пренасяне на редовете",
"session.websearch.title": "Уеб търсене на трети страни",
"session.websearch.description": "Изберете доставчика на търсене, който агентите използват за търсене в мрежата",
"session.websearch.provider": "Доставчик на търсене",
@@ -781,8 +776,6 @@ 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}}",
+6 -8
View File
@@ -1,4 +1,8 @@
export const dict: Record<string, string> = {
import en from "./en"
type Keys = keyof typeof en
export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "ফাইল",
"desktop.menu.edit": "সম্পাদনা করুন",
@@ -296,10 +300,6 @@ export const dict: Record<string, string> = {
"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,7 +713,6 @@ export const dict: Record<string, string> = {
"session.view.select": "সেশন দৃশ্য",
"session.background.moveRunning": "ব্যাকগ্রাউন্ডে নিন",
"session.timeline.working": "কাজ চলছে",
"session.review.wrapLines": "লাইন মোড়ানো",
"session.websearch.title": "তৃতীয়-পক্ষের ওয়েব অনুসন্ধান",
"session.websearch.description":
"এজেন্টরা ওয়েবে অনুসন্ধানের জন্য যে অনুসন্ধান প্রদানকারী ব্যবহার করবে সেটি বেছে নিন",
@@ -774,7 +773,6 @@ export const dict: Record<string, string> = {
"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}}",
@@ -898,4 +896,4 @@ export const dict: Record<string, string> = {
"prompt.toast.uploading.percent": "{{percent}}%",
"prompt.toast.uploading.cancelFile": "{{filename}}-এর আপলোড বাতিল করুন",
"prompt.toast.uploadFailed.title": "আপলোড ব্যর্থ হয়েছে",
}
} satisfies Partial<Record<Keys, string>>
-7
View File
@@ -132,7 +132,6 @@ 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",
@@ -193,8 +192,6 @@ 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",
@@ -796,10 +793,6 @@ 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)",
-7
View File
@@ -624,10 +624,6 @@ 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)",
@@ -783,7 +779,6 @@ 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",
@@ -843,8 +838,6 @@ 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",
-7
View File
@@ -296,10 +296,6 @@ 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",
@@ -723,7 +719,6 @@ 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",
@@ -784,8 +779,6 @@ 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",
-7
View File
@@ -296,10 +296,6 @@ 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í",
@@ -725,7 +721,6 @@ 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í",
@@ -784,8 +779,6 @@ 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",
-6
View File
@@ -121,7 +121,6 @@ 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",
@@ -180,7 +179,6 @@ 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",
@@ -759,10 +757,6 @@ 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)",
-7
View File
@@ -136,7 +136,6 @@ 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",
@@ -198,8 +197,6 @@ 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",
@@ -733,10 +730,6 @@ 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