mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
416 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1803aaf857 | |||
| 00ab94c44f | |||
| fe09a2e9b7 | |||
| 59202356fe | |||
| 637282f3b9 | |||
| d1550cb599 | |||
| ed6c117184 | |||
| 6ce62bd84a | |||
| a4a948316b | |||
| aca3b7813d | |||
| 0eaa75ec0f | |||
| 6b3c4f5839 | |||
| 8e76adb08f | |||
| 0bb24a46c1 | |||
| d3f4695af3 | |||
| 5b2715d24f | |||
| 20e37e7122 | |||
| b87bb4486f | |||
| feaec7a6be | |||
| 1c67004999 | |||
| 43ecf3ff1b | |||
| 9028c2d8f8 | |||
| be18f22842 | |||
| 6216dbae15 | |||
| 96a9731947 | |||
| 768a69bbbd | |||
| b7b941c354 | |||
| cf5e0adf02 | |||
| 41d0a9f010 | |||
| accaa792d8 | |||
| 76d7a11585 | |||
| c867e66ab5 | |||
| 278c510549 | |||
| 0d6ccd2a50 | |||
| 39cceeb143 | |||
| a6449cb45c | |||
| 6a85f0d3db | |||
| fbb0fdf88a | |||
| 2a4b298108 | |||
| efeff63749 | |||
| d54038b9d2 | |||
| 8f04a09d1a | |||
| 761f37370f | |||
| b452368b3b | |||
| 3785eddfa0 | |||
| 4a006b1210 | |||
| 2db7ccb453 | |||
| 629b304c48 | |||
| 1004fbb2f5 | |||
| a7746379d5 | |||
| a72992e00f | |||
| 99156c10e6 | |||
| f6f0a5c10f | |||
| e2eed76101 | |||
| 9ff7ef3fb0 | |||
| 8fe78c8f8d | |||
| bd0dffd781 | |||
| 0ac8f91ed6 | |||
| 4c80c23e11 | |||
| 33c0cc2bb6 | |||
| cc686ab8f6 | |||
| 6524dfc818 | |||
| c91604d519 | |||
| a2718177be | |||
| 6fb526741b | |||
| 85fdb3bde6 | |||
| b642f9c7bc | |||
| 855a9569b5 | |||
| aa0e626b12 | |||
| 389c866cac | |||
| 7feefb697f | |||
| d4155f2906 | |||
| 67f2393f83 | |||
| cccd013801 | |||
| be7a684791 | |||
| 984cab7938 | |||
| 7eea97184a | |||
| 0590819090 | |||
| 687dbba6a3 | |||
| 2347f4d7b4 | |||
| 7698a5e6ac | |||
| ec8eea7cbe | |||
| b44a981eef | |||
| 79415f625a | |||
| 19f42f7102 | |||
| 212c9f99ee | |||
| 99e52303e6 | |||
| 88925ec6e6 | |||
| cedf365674 | |||
| 5b39972947 | |||
| 09553d46e0 | |||
| e1d72fa338 | |||
| b3cf749a7f | |||
| dd842f9862 | |||
| 3dd29094f4 | |||
| 05e8d5be73 | |||
| 8b634e4a58 | |||
| c6156f171c | |||
| 4d2b06f8cf | |||
| bf15c97e4b | |||
| 3cbcd8d727 | |||
| cc29f86cdc | |||
| 86e3828da6 | |||
| 8d00a51766 | |||
| ed4f833813 | |||
| e96c24ce2e | |||
| ed6ad272ec | |||
| d27746e5b3 | |||
| eb661e6e6d | |||
| 3379e44abb | |||
| e1abf59c59 | |||
| 4f976bcf1a | |||
| e25a724bc2 | |||
| 521ea87192 | |||
| 3100701488 | |||
| 9c54291ae8 | |||
| 52ad916ba4 | |||
| 5db320b02d | |||
| 910e37f6d8 | |||
| 81f6e06681 | |||
| da68e2865e | |||
| f86e1cc1ff | |||
| 947bbf9490 | |||
| 3e57b43a4a | |||
| cb18a42a47 | |||
| 1fb2837fd3 | |||
| 66a8d830aa | |||
| d0733f83bf | |||
| b2ce195bda | |||
| 6c065ca6a5 | |||
| dd1c007877 | |||
| 8deb0e5780 | |||
| f488089179 | |||
| e3a39b214f | |||
| b6a2912bb1 | |||
| 93b7ca9b4d | |||
| fcb1d4b418 | |||
| 04b673432c | |||
| bd947658bb | |||
| bb6ee0a5cc | |||
| fd4497e6b9 | |||
| 59790380de | |||
| 7668eb728d | |||
| 79301fbed5 | |||
| c0ea1a43f9 | |||
| 96717c1a8c | |||
| e312f0d775 | |||
| bfdbf43ef8 | |||
| 984430c97d | |||
| bf01264661 | |||
| b046bbe4e3 | |||
| 8dc9ac07a2 | |||
| ba24037e64 | |||
| df471872de | |||
| 8942f97998 | |||
| 754dd57177 | |||
| f044def957 | |||
| 9c2842b9b0 | |||
| 9e0d3976e1 | |||
| f87998f37f | |||
| e57d9ca390 | |||
| d603eed5a1 | |||
| 0e1c0414c2 | |||
| dc99c600b6 | |||
| 6ffe61b9fe | |||
| 36d17c30a7 | |||
| 4d100de194 | |||
| c13f06c30c | |||
| 91f1815732 | |||
| 1a52e1118e | |||
| f2f62438b8 | |||
| 2830176972 | |||
| 06dcf3f221 | |||
| f9442e7107 | |||
| ff499c3603 | |||
| 4cee5bd824 | |||
| f406fed924 | |||
| 5b2d90a441 | |||
| 04e8801f5b | |||
| 7f008df79d | |||
| 175d200e53 | |||
| 41d11a8a89 | |||
| 0bba8c780a | |||
| 32cf36de9d | |||
| fb75ea2cf6 | |||
| 147da5d278 | |||
| eed23d8ee9 | |||
| e6e951b252 | |||
| 4e019ba5d9 | |||
| 50a762e7b9 | |||
| 780c99bc2e | |||
| 5bcf8d5a0b | |||
| 652e8ff3fb | |||
| 7b88de47c3 | |||
| 33cb536879 | |||
| 6ffecf9345 | |||
| 08741f6b93 | |||
| baacb2e776 | |||
| a75978815a | |||
| c3d26c4912 | |||
| a9b7bd9e2f | |||
| f9d1d3b259 | |||
| f950497173 | |||
| 391aa38281 | |||
| 29e8502bb0 | |||
| 44b182fe23 | |||
| 905123b9c0 | |||
| bb3b6a2f65 | |||
| b8efb33cde | |||
| d65ecd4a90 | |||
| 57fb3e5cc5 | |||
| ba07481b59 | |||
| b99759c7de | |||
| c590e27639 | |||
| 8f4b62eb49 | |||
| 945d1c8cb2 | |||
| 610e618bc5 | |||
| 9daa4d85a4 | |||
| 62af66a74f | |||
| 5b44e5bf41 | |||
| a15afbe8f2 | |||
| 8e0856c43b | |||
| f66c829231 | |||
| 3baaabede8 | |||
| afe3ebbc35 | |||
| e2faeb84e5 | |||
| 35ed09ff37 | |||
| 9751615651 | |||
| c9b24ef027 | |||
| b04d8d53e6 | |||
| 7bd3f8ac83 | |||
| 6ae2fa5196 | |||
| 650d774372 | |||
| 64e4f6f91b | |||
| d097cc8065 | |||
| 438654768c | |||
| 37b26e495b | |||
| 5d14c7a185 | |||
| e66cbf36e9 | |||
| 50977dd4fe | |||
| a644e0e7a0 | |||
| ca861fdf43 | |||
| 34a08cbdb8 | |||
| bf3ae45439 | |||
| 394e0b9045 | |||
| 4790a2772c | |||
| 2ff19171dc | |||
| 1401901529 | |||
| 1b88ff8d53 | |||
| 08293e74a0 | |||
| 24d26365e6 | |||
| c22793d5f6 | |||
| c6a52a39b5 | |||
| aad8d90dd1 | |||
| 6f47459c68 | |||
| dd768e30e2 | |||
| de476aa51b | |||
| bd8d858bf7 | |||
| 5fc4e18b82 | |||
| 12d9f4b29b | |||
| a2769b5ade | |||
| 1fa605ad5e | |||
| 698ef25f33 | |||
| 88dc960af8 | |||
| 5657cec4f2 | |||
| 8d790a9ed5 | |||
| b62d24e40b | |||
| a1b6c84135 | |||
| 85fde8c11d | |||
| c167bde6a0 | |||
| d9bf30fc22 | |||
| 3ce5e9800d | |||
| af5eabcb26 | |||
| 0087dd56d9 | |||
| ef2140d121 | |||
| 1de3c6e4a6 | |||
| b6c3a8ea7b | |||
| efcf2c3f5d | |||
| e65477ab1d | |||
| cd0b274856 | |||
| 57e9e9771d | |||
| 1aae92c42a | |||
| 6555df912a | |||
| 7ebd344fa2 | |||
| 460cdc5aec | |||
| b0ca5520a1 | |||
| bc2e270f82 | |||
| 33705e632a | |||
| cff2345c12 | |||
| 0405518180 | |||
| c9604c86ec | |||
| 967c44552e | |||
| ec95694e2f | |||
| c176baee82 | |||
| 016d296f7c | |||
| dbaa53329c | |||
| 7843f8fb38 | |||
| 4045041554 | |||
| 7ec7413fdb | |||
| f016392368 | |||
| 140224b0fc | |||
| cfd35c9354 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be | |||
| 041215996c | |||
| d0b920e187 | |||
| 8c94e9005f | |||
| 932a40cfd9 | |||
| 716f6658db | |||
| 9f1a8f2149 | |||
| b3650e2316 | |||
| fb884bb91e | |||
| 98c09884bf | |||
| 5e47501b8b | |||
| d51ba6ed94 | |||
| a6983b65fc | |||
| 1a06198954 | |||
| 72ec09cf74 | |||
| e2bca216a2 | |||
| a10733dbf4 | |||
| b5823d1077 | |||
| e1fafa2e54 | |||
| ae49b21376 | |||
| 5710064ae1 | |||
| 4617b03ca9 | |||
| 6a91a682e4 | |||
| f8626865b9 | |||
| 2324a63fc6 | |||
| 1ce607c230 | |||
| c26f6f95f7 | |||
| d77b87ea65 | |||
| 6ca6566bd3 | |||
| 8dd993d25a | |||
| 24ab17e718 | |||
| 0a60662d71 | |||
| 194b0615e0 | |||
| a5c51e11d0 | |||
| 8f1db7d06d | |||
| b1ca070b3b | |||
| 12887e572e | |||
| 75715e2115 | |||
| a1250cd690 | |||
| 23adaaaeab | |||
| 524ee8fc03 | |||
| 0f9719a7b5 | |||
| 1b83c08b8a | |||
| 02cb350880 | |||
| 4ce830a919 | |||
| 461a1c3ab4 | |||
| 6846542115 | |||
| f928b5be07 | |||
| ecfa918760 | |||
| f80624cf17 | |||
| fe59174c23 | |||
| 2fe057324f | |||
| 19a5b5a05d | |||
| ff4cab03c1 | |||
| b2d46ecd7e | |||
| 360d85a521 | |||
| c65a7d50c1 | |||
| fa73546a86 | |||
| 935ac2db91 | |||
| 01edae4a7f | |||
| 381d67572e | |||
| e8ac44430b | |||
| 5ae93092aa | |||
| 595c6bd4a7 | |||
| 7073e8797f | |||
| f7034a35a8 | |||
| 53b93b6991 | |||
| 6067019434 | |||
| 42a3cf9645 | |||
| 04c6bed240 | |||
| 94e3a29d2f | |||
| 41283933ff | |||
| 11bf8d8a42 | |||
| c22973ab9f | |||
| 612009e0f1 | |||
| a0a6c9545e | |||
| bceb6d0a9e | |||
| 49e7dc191f | |||
| f4720be08e | |||
| d1d7ebc2c6 | |||
| cd942d0669 | |||
| 4741e3ee6b | |||
| 4673bfbaa0 | |||
| b458dd8c63 | |||
| 7edd6c3a1d | |||
| f43f066741 | |||
| b6553d14e1 | |||
| ab0042a666 | |||
| 5df049d081 | |||
| 573ab9c24b | |||
| 6a16c41e8f | |||
| a491cbee64 | |||
| 658cbe9caf | |||
| 62b2bc39df | |||
| beb2c52c3f | |||
| 655adbf46e | |||
| ac2a78391f | |||
| 9a9bdaba95 | |||
| 8b682c42b6 | |||
| ad4f1c1018 | |||
| a0afb63ed0 | |||
| df9ecb8f6a | |||
| e6f660fecf |
@@ -18,4 +18,4 @@ simonklee
|
|||||||
Slickstef11
|
Slickstef11
|
||||||
usrnk1
|
usrnk1
|
||||||
vimtor
|
vimtor
|
||||||
StarpTech
|
starptech
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- ci
|
- ci
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
- beta
|
- beta
|
||||||
- fix/npm-native-binary-install
|
- fix/npm-native-binary-install
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
@@ -31,6 +32,9 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
@@ -122,7 +126,7 @@ jobs:
|
|||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
@@ -219,8 +223,9 @@ jobs:
|
|||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
|
- build-cli
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -315,7 +320,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
@@ -339,7 +347,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
CSC_KEYCHAIN: build.keychain
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
@@ -442,6 +451,7 @@ jobs:
|
|||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-signed-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -74,13 +75,9 @@ jobs:
|
|||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
run: bun run check:generated
|
run: bun run check:generated
|
||||||
|
|
||||||
- name: Run HttpApi exerciser gates
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
working-directory: packages/opencode
|
|
||||||
run: bun run test:httpapi
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
|
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: typecheck
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev, v2]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [dev]
|
branches: [dev, v2]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ tmp
|
|||||||
dist
|
dist
|
||||||
ts-dist
|
ts-dist
|
||||||
.turbo
|
.turbo
|
||||||
|
.typecheck-profiles
|
||||||
**/.serena
|
**/.serena
|
||||||
.serena/
|
.serena/
|
||||||
**/.omo
|
**/.omo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/gpt-5.6-sol
|
model: opencode/claude-opus-4-8
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
name: opencode-drive
|
||||||
|
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenCode Drive
|
||||||
|
|
||||||
|
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
|
||||||
|
|
||||||
|
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
|
||||||
|
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
|
||||||
|
on changes).
|
||||||
|
|
||||||
|
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
|
||||||
|
stops all processes, and cleans up all artifacts.
|
||||||
|
|
||||||
|
# Prepare The Environment
|
||||||
|
|
||||||
|
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
artifacts=$(opencode-drive init --name demo)
|
||||||
|
cp -R ./fixtures/home/. "$artifacts/"
|
||||||
|
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||||
|
opencode-drive start --name demo --dev ~/projects/opencode
|
||||||
|
```
|
||||||
|
|
||||||
|
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
|
||||||
|
|
||||||
|
# Scripted usage
|
||||||
|
|
||||||
|
You can write scripts that walk through entire flows, and gives you full access to controlling
|
||||||
|
the backend too. See examples of the script API at the bottom of this file.
|
||||||
|
|
||||||
|
After creating or editing a script, always typecheck it before running. Never skip this step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive check ./reproduce-stale-exploring-empty.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it by passing `--script` to start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
It will output information about the run, including paths to log files which you can read
|
||||||
|
to inspect what happened. If you need to dig into failures that aren't clear, read those log
|
||||||
|
files. If the script is unsuccessful, automatically fix the script and run it again.
|
||||||
|
|
||||||
|
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||||
|
and `fs.writeFile` always writes inside the simulated project.
|
||||||
|
|
||||||
|
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineScript } from "opencode-drive"
|
||||||
|
|
||||||
|
export default defineScript({
|
||||||
|
async setup({ fs, config }) {
|
||||||
|
config.autoupdate = false
|
||||||
|
await fs.writeFile("src/example.ts", "export const value = 1\n")
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ ui, llm }) {
|
||||||
|
await ui.submit("Open src/example.ts")
|
||||||
|
await llm.send(llm.text("The file exports `value`."))
|
||||||
|
await ui.waitFor("The file exports `value`.")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup` receives the current OpenCode config object, which starts from the
|
||||||
|
default drive config unless the prepared instance already has one. When a script
|
||||||
|
needs custom config, mutate this `config` parameter instead of generating and
|
||||||
|
writing a new config object from scratch, so the script keeps the default
|
||||||
|
provider/model settings unless it intentionally changes them.
|
||||||
|
|
||||||
|
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
|
||||||
|
|
||||||
|
Use `launch: "manual"` when the script needs to launch the server and every TUI
|
||||||
|
itself (this is extremely rare, do not use this unless explicitly asked). In this
|
||||||
|
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||||
|
once before launching clients. Each `clients.launch(name)` result provides the
|
||||||
|
same UI methods as the automatic client. You can see an example of this API
|
||||||
|
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||||
|
|
||||||
|
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||||
|
|
||||||
|
`await llm.send(...)` waits for the next request and resolves after OpenCode
|
||||||
|
acknowledges its complete response. `llm.queue(...)` declares responses in
|
||||||
|
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
|
||||||
|
`finish`, and `disconnect`. A normal response receives `finish("stop")`
|
||||||
|
automatically unless it yields or queues an explicit terminal event.
|
||||||
|
|
||||||
|
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
|
||||||
|
15-character target varied by plus or minus 5 per chunk.
|
||||||
|
|
||||||
|
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
|
||||||
|
delay between any two outputs.
|
||||||
|
|
||||||
|
Use `llm.serve` for an ongoing typed response generator:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
llm.serve(async function* (request, index) {
|
||||||
|
yield llm.reasoning(`Handling request ${index + 1}`)
|
||||||
|
yield llm.text(`Received ${request.id}`)
|
||||||
|
yield llm.finish("stop")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend connection, response cleanup, cancellation, and recording
|
||||||
|
completion are automatic.
|
||||||
|
|
||||||
|
You can see some example scripts here:
|
||||||
|
|
||||||
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||||
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||||
|
|
||||||
|
## Prune
|
||||||
|
|
||||||
|
- `prune` removes artifact directories. These are always cleaned up after running a script
|
||||||
|
successfully, but leftover on failed runs. Always call this if a script fails.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive prune --name demo
|
||||||
|
|
||||||
|
// --force cleans up all artifcat directories
|
||||||
|
opencode-dirve prune --force
|
||||||
|
```
|
||||||
|
|
||||||
|
# Live interaction usage
|
||||||
|
|
||||||
|
- Always give headless instances a unique `--name`. Visible instances may omit it.
|
||||||
|
- A normal headless `start` detaches automatically and returns after the instance is ready.
|
||||||
|
- Do not add `&`; the long-running owner already runs in the background.
|
||||||
|
- Configure simulated model responses after startup when needed.
|
||||||
|
- Send ordered UI commands with `send`.
|
||||||
|
- Always stop the instance when finished.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive start --name demo
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.type '{"text":"Explain this project"}' \
|
||||||
|
--command.ui.enter
|
||||||
|
|
||||||
|
opencode-drive stop --name demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Send UI Commands
|
||||||
|
|
||||||
|
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
|
||||||
|
- Combine typing and Enter in one command when submitting a prompt.
|
||||||
|
- JSON-valued commands require one JSON argument.
|
||||||
|
- Multiple command flags execute from left to right.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
|
||||||
|
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
|
||||||
|
- `--command.ui.enter` presses Enter. Arguments: none.
|
||||||
|
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
|
||||||
|
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
|
||||||
|
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
|
||||||
|
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
|
||||||
|
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
|
||||||
|
--command.ui.enter
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.arrow '{"direction":"down"}'
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.focus '{"target":12}'
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.click '{"target":12,"x":4,"y":1}'
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.matches '{"text":"OpenCode"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
To read the UI state and see information about interactable elements, use the `ui.state` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive send --name demo --command.ui.state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure LLM Responses
|
||||||
|
|
||||||
|
- `responses` controls what the LLM responds with
|
||||||
|
- Only use this if you are wanting to reproduce an exact type of response
|
||||||
|
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
|
||||||
|
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
|
||||||
|
- `--tools` limits generated tool calls to names offered by OpenCode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive responses --name demo \
|
||||||
|
--types text,reasoning,diff,tool \
|
||||||
|
--tools write,apply_patch
|
||||||
|
|
||||||
|
opencode-drive responses --name demo \
|
||||||
|
--types tool \
|
||||||
|
--tools read,glob,grep
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inspect The UI
|
||||||
|
|
||||||
|
- `ui.state` prints focus and interactive element metadata as JSON.
|
||||||
|
- `ui.matches` checks for literal, case-sensitive screen text.
|
||||||
|
- `screenshot` prints the generated image path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive screenshot --name demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
- `stop` waits for recording export and owner cleanup before returning.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive stop --name demo
|
||||||
|
```
|
||||||
|
|
||||||
|
# Record The UI
|
||||||
|
|
||||||
|
- Start with `--record` to capture a headless instance from its first rendered frame.
|
||||||
|
- `stop` finishes the recording, exports an MP4, and prints its path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive start --name demo --record
|
||||||
|
|
||||||
|
opencode-drive send --name demo \
|
||||||
|
--command.ui.type '{"text":"Show me the current architecture"}' \
|
||||||
|
--command.ui.enter
|
||||||
|
|
||||||
|
opencode-drive stop --name demo
|
||||||
|
```
|
||||||
|
|
||||||
|
# Artifacts dir
|
||||||
|
|
||||||
|
- `dir` prints the artifact directory for the instance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode-drive dir --name demo
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: sample-skill
|
||||||
|
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sample Skill
|
||||||
|
|
||||||
|
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
- Use when the user asks for a sample skill or skill template.
|
||||||
|
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
|
||||||
|
- Do not use for unrelated coding tasks just because a skill exists.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Confirm the specific outcome the user wants if the request is ambiguous.
|
||||||
|
- Inspect the relevant files before changing anything.
|
||||||
|
- Make the smallest correct change.
|
||||||
|
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
|
||||||
|
- Summarize the changed files and any required restart or reload step.
|
||||||
|
|
||||||
|
## Example Response Style
|
||||||
|
|
||||||
|
When this skill is relevant, keep responses direct and actionable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
|
||||||
|
Restart opencode for the new skill to be discovered by future sessions.
|
||||||
|
```
|
||||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
|||||||
const TEAM = {
|
const TEAM = {
|
||||||
tui: ["kommander", "simonklee"],
|
tui: ["kommander", "simonklee"],
|
||||||
desktop_web: ["Hona", "Brendonovich"],
|
desktop_web: ["Hona", "Brendonovich"],
|
||||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
|
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
|
|
||||||
@@ -18,12 +19,15 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
|
|||||||
|
|
||||||
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||||
|
|
||||||
|
Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user.
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
### General Principles
|
### General Principles
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||||
|
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
@@ -150,12 +154,15 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
## V2 Session Core
|
## V2 Session Core
|
||||||
|
|
||||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||||
|
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||||
|
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||||
|
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||||
|
|||||||
+100
-80
@@ -4,40 +4,50 @@ OpenCode sessions preserve durable conversational history while assembling the r
|
|||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
**System Context**:
|
**Model Context**:
|
||||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||||
_Avoid_: System prompt
|
_Avoid_: System Context
|
||||||
|
|
||||||
|
**Instructions**:
|
||||||
|
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||||
|
_Avoid_: Model Context, System Context
|
||||||
|
|
||||||
**Session History**:
|
**Session History**:
|
||||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||||
_Avoid_: Session Context
|
_Avoid_: Session Context
|
||||||
|
|
||||||
**Context Source**:
|
**Instruction Source**:
|
||||||
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||||
_Avoid_: Prompt fragment
|
_Avoid_: Prompt fragment
|
||||||
|
|
||||||
**System Context Registry**:
|
**InstructionEntry**:
|
||||||
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||||
|
|
||||||
**Mid-Conversation System Message**:
|
**InstructionDiscovery**:
|
||||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
|
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||||
_Avoid_: System update, system notification, raw text diff
|
|
||||||
|
|
||||||
**Context Epoch**:
|
**Instruction State**:
|
||||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||||
|
|
||||||
**Baseline System Context**:
|
**Instruction Update**:
|
||||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||||
|
_Avoid_: Correction, stored prose, raw text diff
|
||||||
|
|
||||||
|
**Initial Instructions**:
|
||||||
|
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||||
_Avoid_: Live system prompt
|
_Avoid_: Live system prompt
|
||||||
|
|
||||||
**Context Snapshot**:
|
**Instruction Epoch**:
|
||||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||||
|
|
||||||
**Unavailable Context**:
|
**Instruction Values**:
|
||||||
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||||
|
|
||||||
**Safe Provider-Turn Boundary**:
|
**Unavailable Instruction Source**:
|
||||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||||
|
|
||||||
|
**Safe Step Boundary**:
|
||||||
|
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||||
|
|
||||||
**Admitted Prompt**:
|
**Admitted Prompt**:
|
||||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||||
@@ -45,11 +55,24 @@ A durable user input accepted into the Session inbox but not yet included in **S
|
|||||||
**Prompt Promotion**:
|
**Prompt Promotion**:
|
||||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||||
|
|
||||||
**Provider Turn**:
|
**Step**:
|
||||||
One request to a model provider and the response projected from that request.
|
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||||
|
_Avoid_: provider turn, turn (unqualified)
|
||||||
|
|
||||||
|
**Physical Attempt**:
|
||||||
|
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||||
|
|
||||||
|
**Assistant Turn**:
|
||||||
|
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||||
|
|
||||||
|
**Settlement**:
|
||||||
|
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||||
|
|
||||||
|
**Execution**:
|
||||||
|
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||||
|
|
||||||
**Session Drain**:
|
**Session Drain**:
|
||||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||||
|
|
||||||
**Model Tool Output**:
|
**Model Tool Output**:
|
||||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||||
@@ -87,52 +110,49 @@ _Avoid_: Response envelope
|
|||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||||
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||||
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||||
|
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||||
|
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||||
|
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||||
|
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||||
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
|
||||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
|
||||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
|
||||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
|
||||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
|
||||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
|
||||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
|
||||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||||
@@ -162,12 +182,12 @@ _Avoid_: Response envelope
|
|||||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||||
@@ -175,27 +195,27 @@ _Avoid_: Response envelope
|
|||||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||||
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||||
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||||
|
|
||||||
## Client contract architecture
|
## Client contract architecture
|
||||||
@@ -217,9 +237,9 @@ Before stabilizing the client API:
|
|||||||
|
|
||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
|
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||||
|
|||||||
@@ -252,6 +252,33 @@ These are not strictly enforced, they are just general guidelines:
|
|||||||
|
|
||||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||||
|
|
||||||
|
## Trust & Vouch System
|
||||||
|
|
||||||
|
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- **Vouched users** are explicitly trusted contributors.
|
||||||
|
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
||||||
|
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
||||||
|
|
||||||
|
### For maintainers
|
||||||
|
|
||||||
|
Collaborators with write access can manage the vouch list by commenting on any issue:
|
||||||
|
|
||||||
|
- `vouch` — vouch for the issue author
|
||||||
|
- `vouch @username` — vouch for a specific user
|
||||||
|
- `denounce` — denounce the issue author
|
||||||
|
- `denounce @username` — denounce a specific user
|
||||||
|
- `denounce @username <reason>` — denounce with a reason
|
||||||
|
- `unvouch` / `unvouch @username` — remove someone from the list
|
||||||
|
|
||||||
|
Changes are committed automatically to `.github/VOUCHED.td`.
|
||||||
|
|
||||||
|
### Denouncement policy
|
||||||
|
|
||||||
|
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
||||||
|
|
||||||
## Issue Requirements
|
## Issue Requirements
|
||||||
|
|
||||||
All issues **must** use one of our issue templates:
|
All issues **must** use one of our issue templates:
|
||||||
|
|||||||
@@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
|
|||||||
console.log("Subscribing to session events...")
|
console.log("Subscribing to session events...")
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
|
||||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||||
|
|||||||
+2
-6
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
server: {
|
server: {
|
||||||
placement: { region: "aws:us-east-2" },
|
placement: { region: "aws:us-east-2" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: (args) => {
|
worker: {
|
||||||
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
...(flags ?? []),
|
|
||||||
"global_fetch_strictly_public",
|
|
||||||
])
|
|
||||||
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,25 +8,6 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
|||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
if ($app.stage === "production") {
|
|
||||||
new cloudflare.DnsRecord("TrustCenter", {
|
|
||||||
zoneId: zoneID,
|
|
||||||
name: "trust.opencode.ai",
|
|
||||||
type: "CNAME",
|
|
||||||
content: "3a69a5bb27875189.vercel-dns-016.com",
|
|
||||||
proxied: false,
|
|
||||||
ttl: 60,
|
|
||||||
})
|
|
||||||
|
|
||||||
new cloudflare.DnsRecord("TrustCenterVerification", {
|
|
||||||
zoneId: zoneID,
|
|
||||||
name: "opencode.ai",
|
|
||||||
type: "TXT",
|
|
||||||
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
|
||||||
ttl: 60,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|||||||
+33
-66
@@ -8,8 +8,6 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
copyDesktopItems,
|
|
||||||
makeDesktopItem,
|
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
]
|
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
++ lib.optionals stdenv.hostPlatform.isLinux [
|
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
copyDesktopItems
|
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
]
|
|
||||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
@@ -43,37 +38,20 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
|
||||||
name = "ai.opencode.desktop";
|
|
||||||
desktopName = "OpenCode";
|
|
||||||
exec = "opencode-desktop %U";
|
|
||||||
icon = "ai.opencode.desktop";
|
|
||||||
# Electron 41 derives X11 WM_CLASS from app.name.
|
|
||||||
startupWMClass = "OpenCode";
|
|
||||||
categories = [ "Development" ];
|
|
||||||
});
|
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
postPatch =
|
# https://github.com/electron/electron/issues/31121
|
||||||
# NOTE: Relax Bun version check to be a warning instead of an error
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
''
|
postPatch = lib.optionalString stdenv.isLinux ''
|
||||||
substituteInPlace packages/script/src/index.ts \
|
BASE_PATH=packages/desktop
|
||||||
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
FILES=(src/main/windows.ts)
|
||||||
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
for file in "''${FILES[@]}"; do
|
||||||
''
|
substituteInPlace $BASE_PATH/$file \
|
||||||
# https://github.com/electron/electron/issues/31121
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
done
|
||||||
+ lib.optionalString stdenv.isLinux ''
|
'';
|
||||||
BASE_PATH=packages/desktop
|
|
||||||
FILES=(src/main/windows.ts)
|
|
||||||
for file in "''${FILES[@]}"; do
|
|
||||||
substituteInPlace $BASE_PATH/$file \
|
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
|
||||||
done
|
|
||||||
'';
|
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
@@ -98,38 +76,27 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase =
|
||||||
runHook preInstall
|
''
|
||||||
''
|
runHook preInstall
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
''
|
||||||
mkdir -p $out/Applications
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
mv dist/mac*/*.app $out/Applications
|
mkdir -p $out/Applications
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
mv dist/mac*/*.app $out/Applications
|
||||||
''
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
''
|
||||||
mkdir -p $out/opt/opencode-desktop
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
mkdir -p $out/opt/opencode-desktop
|
||||||
install -Dm644 resources/icons/32x32.png \
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
install -Dm644 resources/icons/64x64.png \
|
--inherit-argv0 \
|
||||||
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
install -Dm644 resources/icons/128x128.png \
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
install -Dm644 resources/icons/128x128@2x.png \
|
''
|
||||||
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
+ ''
|
||||||
install -Dm644 resources/icons/icon.png \
|
runHook postInstall
|
||||||
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
'';
|
||||||
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
|
||||||
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
|
||||||
--inherit-argv0 \
|
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
|
||||||
''
|
|
||||||
+ ''
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
||||||
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
||||||
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
||||||
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -2,18 +2,23 @@
|
|||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"typecheck": "bun turbo typecheck",
|
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||||
|
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||||
|
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||||
|
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||||
|
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -40,10 +45,10 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.4.5",
|
"@opentui/core": "0.4.3",
|
||||||
"@opentui/keymap": "0.4.5",
|
"@opentui/keymap": "0.4.3",
|
||||||
"@opentui/solid": "0.4.5",
|
"@opentui/solid": "0.4.3",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.28",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -70,7 +75,7 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.6",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
@@ -90,13 +95,13 @@
|
|||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-sonner": "0.3.1",
|
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"@lydell/node-pty": "1.2.0-beta.12"
|
"@lydell/node-pty": "1.2.0-beta.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
|
"@ast-grep/cli": "0.44.0",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -144,20 +149,19 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
|
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
# V1 API Migration Checklist
|
|
||||||
|
|
||||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
|
||||||
- `src/context/server-sdk.tsx`
|
|
||||||
- [x] Reduce current granular session and message events into the existing app projections.
|
|
||||||
- `src/context/server-session-v2-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- `src/context/notification.tsx`
|
|
||||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
|
||||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [x] Adapt current permission and question events to the existing request model.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- [x] Consume current file watcher events.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- [x] Consume current VCS events.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Consume current `pty.exited` events.
|
|
||||||
- `src/context/terminal.tsx`
|
|
||||||
- [ ] Migrate LSP and reference events.
|
|
||||||
- `src/context/global-sync/event-reducer.ts`
|
|
||||||
|
|
||||||
## Sessions
|
|
||||||
|
|
||||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate session listing from `GET /session`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- `src/context/directory-sync.ts`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
|
||||||
- `src/components/titlebar.tsx`
|
|
||||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
|
||||||
- `src/context/directory-sync.ts`
|
|
||||||
- `src/context/layout.tsx`
|
|
||||||
- `src/pages/home.tsx`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- `src/components/titlebar-tab-nav.tsx`
|
|
||||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
|
||||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
|
||||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
|
||||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
|
||||||
- `src/components/dialog-fork.tsx`
|
|
||||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
|
||||||
- `src/pages/session/use-session-commands.tsx`
|
|
||||||
- `src/pages/session/timeline/message-timeline.tsx`
|
|
||||||
- Blocked: the current API has no sharing contract or implementation.
|
|
||||||
|
|
||||||
## Session Compatibility Fallbacks
|
|
||||||
|
|
||||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
|
||||||
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
|
||||||
- `src/context/server-session.ts`
|
|
||||||
|
|
||||||
## Filesystem
|
|
||||||
|
|
||||||
- [ ] Migrate file listing from `GET /file`.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- [ ] Migrate file reads from `GET /file/content`.
|
|
||||||
- `src/context/file.tsx`
|
|
||||||
- `src/pages/session/review-tab.tsx`
|
|
||||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
|
||||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/components/dialog-select-directory.tsx`
|
|
||||||
- `src/components/dialog-select-directory-v2.tsx`
|
|
||||||
|
|
||||||
## Projects And Worktrees
|
|
||||||
|
|
||||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
|
||||||
- `src/context/layout.tsx`
|
|
||||||
- `src/components/edit-project.ts`
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
- `src/components/prompt-input/submit.ts`
|
|
||||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
|
||||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
|
|
||||||
## VCS
|
|
||||||
|
|
||||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
|
||||||
- `src/pages/session.tsx`
|
|
||||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
|
||||||
- `src/pages/layout.tsx`
|
|
||||||
|
|
||||||
## Configuration And Authentication
|
|
||||||
|
|
||||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
|
||||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- `src/components/dialog-custom-provider.tsx`
|
|
||||||
- `src/components/settings-providers.tsx`
|
|
||||||
- `src/components/settings-v2/providers.tsx`
|
|
||||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
|
||||||
- `src/components/dialog-connect-provider.tsx`
|
|
||||||
- `src/components/settings-providers.tsx`
|
|
||||||
- `src/components/settings-v2/providers.tsx`
|
|
||||||
|
|
||||||
## Permissions And Questions
|
|
||||||
|
|
||||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
|
||||||
- `src/context/permission.tsx`
|
|
||||||
- `src/pages/session/composer/session-composer-state.ts`
|
|
||||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
|
||||||
- `src/pages/session/composer/session-question-dock.tsx`
|
|
||||||
|
|
||||||
## Commands, MCP, LSP, And References
|
|
||||||
|
|
||||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [ ] Migrate LSP status from `GET /lsp`.
|
|
||||||
- `src/context/server-sync.tsx`
|
|
||||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
|
||||||
- `src/context/global-sync/bootstrap.ts`
|
|
||||||
|
|
||||||
## Search
|
|
||||||
|
|
||||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
|
||||||
- `src/components/command-palette.ts`
|
|
||||||
- `src/components/dialog-command-palette-v2.tsx`
|
|
||||||
|
|
||||||
## PTY And Terminal
|
|
||||||
|
|
||||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
|
||||||
- `src/context/terminal.tsx`
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
|
||||||
- `src/components/settings-general.tsx`
|
|
||||||
- `src/components/settings-v2/general.tsx`
|
|
||||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
|
||||||
- `src/components/terminal.tsx`
|
|
||||||
|
|
||||||
## Legacy Types And Adapters
|
|
||||||
|
|
||||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
|
||||||
|
|
||||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
|
||||||
- `src/utils/session.ts`
|
|
||||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
|
||||||
- `src/utils/session-message.ts`
|
|
||||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
|
||||||
- `src/context/global-sync/utils.ts`
|
|
||||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
|
||||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
|
||||||
- `package.json`
|
|
||||||
|
|
||||||
## Test Infrastructure
|
|
||||||
|
|
||||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
|
||||||
- `e2e/utils/mock-server.ts`
|
|
||||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
|
||||||
- `e2e/utils/sse-transport.ts`
|
|
||||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
|
||||||
- `e2e/performance/timeline-stability/fixture.ts`
|
|
||||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
|
||||||
@@ -20,7 +20,7 @@ const profiles = [
|
|||||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||||
{
|
{
|
||||||
name: "multi patch",
|
name: "multi patch",
|
||||||
tool: "apply_patch",
|
tool: "patch",
|
||||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
userMessage(),
|
userMessage(),
|
||||||
assistantMessage(
|
assistantMessage(
|
||||||
[
|
[
|
||||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||||
textPart(followingID, "Following incremental patch"),
|
textPart(followingID, "Following incremental patch"),
|
||||||
],
|
],
|
||||||
{ completed: false },
|
{ completed: false },
|
||||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"apply_patch",
|
"patch",
|
||||||
"running",
|
"running",
|
||||||
{ files: [first.filePath, second.filePath] },
|
{ files: [first.filePath, second.filePath] },
|
||||||
{ metadata: { files: [first, second] } },
|
{ metadata: { files: [first, second] } },
|
||||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"apply_patch",
|
"patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||||
{ metadata: { files: [first, second, third] } },
|
{ metadata: { files: [first, second, third] } },
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export async function setupTimeline(
|
|||||||
locale?: string
|
locale?: string
|
||||||
deviceScaleFactor?: number
|
deviceScaleFactor?: number
|
||||||
seedHistory?: boolean
|
seedHistory?: boolean
|
||||||
protocol?: "v1" | "v2"
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
@@ -115,7 +114,6 @@ export async function setupTimeline(
|
|||||||
retry: input.eventRetry ?? 20,
|
retry: input.eventRetry ?? 20,
|
||||||
})
|
})
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: input.protocol,
|
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
@@ -138,9 +136,6 @@ export async function setupTimeline(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (settings.newLayoutDesigns === false) {
|
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
}
|
|
||||||
}, input.settings ?? {})
|
}, input.settings ?? {})
|
||||||
if (input.locale) {
|
if (input.locale) {
|
||||||
await page.addInitScript((locale) => {
|
await page.addInitScript((locale) => {
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
|
|||||||
}
|
}
|
||||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||||
const questionID = "prt_state_question"
|
const questionID = "prt_state_question"
|
||||||
const todoID = "prt_state_todo"
|
|
||||||
const initial = [
|
const initial = [
|
||||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||||
toolPart(questionID, "question", "pending", questionInput()),
|
toolPart(questionID, "question", "pending", questionInput()),
|
||||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
|
||||||
textPart("prt_state_following", "Following lightweight tools"),
|
textPart("prt_state_following", "Following lightweight tools"),
|
||||||
]
|
]
|
||||||
const childID = "ses_timeline_child"
|
const childID = "ses_timeline_child"
|
||||||
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
|
|||||||
await timeline.send(status("busy"), 120)
|
await timeline.send(status("busy"), 120)
|
||||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
|
||||||
|
|
||||||
const regionIDs = [
|
const regionIDs = [
|
||||||
"prt_state_webfetch",
|
"prt_state_webfetch",
|
||||||
@@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|||||||
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
|||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
const lastID = userID
|
||||||
const lastAssistant = assistants.at(-1)!
|
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||||
const lastPart = lastAssistant.parts.at(-1)!
|
|
||||||
const lastPartID =
|
|
||||||
lastPart.type === "tool"
|
|
||||||
? lastPart.id
|
|
||||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
|
||||||
|
|
||||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
benchmark.setTimeout(180_000)
|
||||||
@@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||||
return route.fulfill({
|
)
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(
|
|
||||||
current
|
|
||||||
? {
|
|
||||||
data: {
|
|
||||||
...target,
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
location: { directory: target.directory },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: target,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
@@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
parent: requests.filter((request) => request.type === "parent").length,
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
}
|
}
|
||||||
if (mode === "candidate") {
|
if (mode === "candidate") {
|
||||||
expect(requestCounts.parent).toBe(0)
|
expect(requestCounts.parent).toBe(1)
|
||||||
expect(historyGates).toBe(0)
|
expect(historyGates).toBe(1)
|
||||||
}
|
}
|
||||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
|||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call_0000_${suffix}_patch`,
|
callID: `call_0000_${suffix}_patch`,
|
||||||
tool: "apply_patch",
|
tool: "patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { patchText: realisticPatch(index) },
|
input: { patchText: realisticPatch(index) },
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function toolPart(
|
|||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
metadataOverride ??
|
metadataOverride ??
|
||||||
(tool === "apply_patch"
|
(tool === "patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0
|
...(index % 7 === 0
|
||||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||||
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
|||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { currentSession } from "../utils/mock-server"
|
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
@@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||||
return sse(route)
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
if (url.pathname === "/session") return json(route, [current])
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
|
||||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
|
||||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||||
|
return json(route, {})
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
if (url.pathname === "/api/path")
|
|
||||||
return json(route, {
|
|
||||||
state: current.directory,
|
|
||||||
config: current.directory,
|
|
||||||
worktree: current.directory,
|
|
||||||
directory: current.directory,
|
|
||||||
home: current.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
if (url.pathname === "/api/vcs")
|
|
||||||
return json(route, {
|
|
||||||
location: { directory: current.directory },
|
|
||||||
data: { branch: "main", defaultBranch: "main" },
|
|
||||||
})
|
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/FileBrowserSidebar"
|
|
||||||
const projectID = "proj_file_browser_sidebar"
|
|
||||||
const sessionID = "ses_file_browser_sidebar"
|
|
||||||
const title = "File browser sidebar"
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
|
||||||
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
|
||||||
const PROBE = "original"
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
|
||||||
|
|
||||||
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
|
||||||
// switches. Remounting resets scroll and filter state.
|
|
||||||
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
|
||||||
await setup(page)
|
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
|
|
||||||
const panel = page.locator("#review-panel")
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
|
||||||
|
|
||||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
|
||||||
await expect(sidebar).toBeVisible()
|
|
||||||
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
|
||||||
|
|
||||||
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
|
||||||
await viewport.hover()
|
|
||||||
await page.mouse.wheel(0, 100_000)
|
|
||||||
await expect
|
|
||||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
|
||||||
expect(scrolled).toBeGreaterThan(0)
|
|
||||||
await writeProbe(page)
|
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
|
||||||
expect(await readProbe(page)).toBe(PROBE)
|
|
||||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
|
||||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
expect(await readProbe(page)).toBe(PROBE)
|
|
||||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
|
||||||
})
|
|
||||||
|
|
||||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
|
||||||
|
|
||||||
async function writeProbe(page: Page) {
|
|
||||||
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
|
||||||
;(el as Probed).__e2eProbe = probe
|
|
||||||
}, PROBE)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readProbe(page: Page) {
|
|
||||||
return page
|
|
||||||
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
|
||||||
.evaluate((el) => (el as Probed).__e2eProbe)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setup(page: Page) {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "file-browser-sidebar",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
|
||||||
},
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
id: sessionID,
|
|
||||||
slug: sessionID,
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
vcsDiff: [],
|
|
||||||
fileList: (path) => {
|
|
||||||
if (path) return []
|
|
||||||
return files.map((name) => ({
|
|
||||||
name,
|
|
||||||
path: name,
|
|
||||||
absolute: `${directory}/${name}`,
|
|
||||||
type: "file" as const,
|
|
||||||
ignored: false,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, server, sessionID }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
|
||||||
lastProject: { local: directory },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:layout",
|
|
||||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:review-panel-v2",
|
|
||||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, server, sessionID },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/OpenFileExpand"
|
|
||||||
const projectID = "proj_open_file_expand"
|
|
||||||
const sessionID = "ses_open_file_expand"
|
|
||||||
const title = "Open file expand"
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
|
||||||
|
|
||||||
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "open-file-expand",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
|
||||||
},
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
id: sessionID,
|
|
||||||
slug: sessionID,
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
vcsDiff: [],
|
|
||||||
fileList: (path) => {
|
|
||||||
if (path === "frontend\\" || path === "frontend") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "app.ts",
|
|
||||||
path: "frontend\\app.ts",
|
|
||||||
absolute: `${directory}/frontend/app.ts`,
|
|
||||||
type: "file" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (path) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "frontend",
|
|
||||||
path: "frontend\\",
|
|
||||||
absolute: `${directory}/frontend`,
|
|
||||||
type: "directory" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "README.md",
|
|
||||||
path: "README.md",
|
|
||||||
absolute: `${directory}/README.md`,
|
|
||||||
type: "file" as const,
|
|
||||||
ignored: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, server, sessionID }) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"settings.v3",
|
|
||||||
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
|
||||||
lastProject: { local: directory },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:layout",
|
|
||||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:review-panel-v2",
|
|
||||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, server, sessionID },
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
|
|
||||||
const panel = page.locator("#review-panel")
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
|
||||||
|
|
||||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
|
||||||
await expect(sidebar).toBeVisible()
|
|
||||||
|
|
||||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
|
||||||
await expect(frontendRow).toBeVisible()
|
|
||||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
|
||||||
await frontendRow.click()
|
|
||||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
|
||||||
|
|
||||||
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
|
||||||
await expect(appRow).toBeVisible()
|
|
||||||
await appRow.click()
|
|
||||||
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
|
||||||
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectAppVisible } from "../utils/waits"
|
|
||||||
|
|
||||||
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
|
||||||
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
|
||||||
|
|
||||||
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
|
||||||
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
|
||||||
const OUTSIDE_CAP = "foxtrot-docs"
|
|
||||||
|
|
||||||
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
|
||||||
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
|
||||||
const rows = (page: Page) => page.locator("[data-directory-path]")
|
|
||||||
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
|
||||||
|
|
||||||
async function openProjectDialog(page: Page) {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages,
|
|
||||||
fileList: () => [],
|
|
||||||
findFiles: () => [],
|
|
||||||
})
|
|
||||||
await page.addInitScript((dirs) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
|
||||||
lastProject: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, worktrees)
|
|
||||||
await page.goto("/")
|
|
||||||
const add = page.getByRole("button", { name: "Add project" }).first()
|
|
||||||
await expectAppVisible(add)
|
|
||||||
await add.click()
|
|
||||||
await expect(rows(page)).toHaveCount(5)
|
|
||||||
return page.getByRole("textbox").last()
|
|
||||||
}
|
|
||||||
|
|
||||||
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
|
||||||
const search = await openProjectDialog(page)
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
|
||||||
|
|
||||||
await search.fill("foxtrot")
|
|
||||||
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("still caps the idle recent list at five projects", async ({ page }) => {
|
|
||||||
await openProjectDialog(page)
|
|
||||||
|
|
||||||
await expect(row(page, NAMES[4])).toHaveCount(1)
|
|
||||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectAppVisible } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
|
||||||
const projectID = "proj_prompt_input_v2_editing"
|
|
||||||
const sessionID = "ses_prompt_input_v2_editing"
|
|
||||||
|
|
||||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "prompt-input-v2-editing",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: { all: [], connected: [], default: {} },
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
id: sessionID,
|
|
||||||
slug: "prompt-input-v2-editing",
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title: "Prompt input V2 editing",
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
})
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
|
||||||
await expectAppVisible(composer)
|
|
||||||
|
|
||||||
await input.fill("keep me")
|
|
||||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
|
||||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
|
||||||
await page.locator('[data-suggestion-id="model.choose"]').click()
|
|
||||||
|
|
||||||
await expect(input).toHaveText("keep me")
|
|
||||||
})
|
|
||||||
@@ -54,15 +54,18 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
const composer = page.locator('[data-component="session-composer"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
const control = composer.locator('[data-component="prompt-variant-control"]')
|
||||||
await expectAppVisible(composer)
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
await idleComposer(page)
|
await idleComposer(page)
|
||||||
|
await expect(control).toBeHidden()
|
||||||
|
|
||||||
|
await composer.hover()
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.click()
|
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
|
||||||
import { currentSession } from "../utils/mock-server"
|
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
|
||||||
const serverB = "http://127.0.0.1:4097"
|
|
||||||
const directoryA = "C:/server-a"
|
|
||||||
const directoryB = "/home/server-b"
|
|
||||||
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
|
||||||
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
|
||||||
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
|
||||||
|
|
||||||
test("session settings use the remote server context", async ({ page }) => {
|
|
||||||
const permissionRequests: string[] = []
|
|
||||||
await mockServers(page, permissionRequests)
|
|
||||||
await configureServers(page)
|
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Control+,")
|
|
||||||
|
|
||||||
const dialog = page.locator(".settings-v2-dialog")
|
|
||||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
|
||||||
const input = autoAccept.getByRole("switch")
|
|
||||||
await expect(autoAccept).toBeVisible()
|
|
||||||
await expect(input).toBeEnabled()
|
|
||||||
permissionRequests.length = 0
|
|
||||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(input).toBeChecked()
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
permissionRequests.some((request) => {
|
|
||||||
const url = new URL(request)
|
|
||||||
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Models" }).click()
|
|
||||||
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
|
||||||
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
|
||||||
const permissionRequests: string[] = []
|
|
||||||
const permissionResponses: PermissionResponse[] = []
|
|
||||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
|
||||||
server: serverA,
|
|
||||||
retry: 20,
|
|
||||||
})
|
|
||||||
await mockServers(page, permissionRequests, permissionResponses)
|
|
||||||
await configureServers(page, [
|
|
||||||
{ type: "session", server: serverA, sessionId: sessionA.id },
|
|
||||||
{ type: "session", server: serverB, sessionId: sessionB.id },
|
|
||||||
])
|
|
||||||
|
|
||||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
|
||||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
|
||||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Control+,")
|
|
||||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
|
||||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
permissionRequests.some((request) => {
|
|
||||||
const url = new URL(request)
|
|
||||||
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
|
|
||||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
|
||||||
await transport.waitForConnection()
|
|
||||||
|
|
||||||
await transport.send({
|
|
||||||
directory: directoryA,
|
|
||||||
payload: {
|
|
||||||
id: "event-permission-background-a",
|
|
||||||
type: "permission.asked",
|
|
||||||
properties: {
|
|
||||||
id: "permission-background-a",
|
|
||||||
sessionID: sessionA.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["git status"],
|
|
||||||
metadata: {},
|
|
||||||
always: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => permissionResponses)
|
|
||||||
.toEqual([
|
|
||||||
{
|
|
||||||
origin: serverA,
|
|
||||||
directory: directoryA,
|
|
||||||
sessionID: sessionA.id,
|
|
||||||
permissionID: "permission-background-a",
|
|
||||||
body: { response: "once" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
await transport.send({
|
|
||||||
directory: directoryA,
|
|
||||||
payload: {
|
|
||||||
id: "event-permission-background-a-child",
|
|
||||||
type: "permission.asked",
|
|
||||||
properties: {
|
|
||||||
id: "permission-background-a-child",
|
|
||||||
sessionID: childSessionA.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["git diff"],
|
|
||||||
metadata: {},
|
|
||||||
always: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => permissionResponses)
|
|
||||||
.toEqual([
|
|
||||||
{
|
|
||||||
origin: serverA,
|
|
||||||
directory: directoryA,
|
|
||||||
sessionID: sessionA.id,
|
|
||||||
permissionID: "permission-background-a",
|
|
||||||
body: { response: "once" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
origin: serverA,
|
|
||||||
directory: directoryA,
|
|
||||||
sessionID: childSessionA.id,
|
|
||||||
permissionID: "permission-background-a-child",
|
|
||||||
body: { response: "once" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
type PermissionResponse = {
|
|
||||||
origin: string
|
|
||||||
directory?: string
|
|
||||||
sessionID: string
|
|
||||||
permissionID: string
|
|
||||||
body: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
|
||||||
await page.addInitScript(
|
|
||||||
({ serverB, tabs }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
|
||||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
|
||||||
},
|
|
||||||
{ serverB, tabs },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
|
||||||
await page.route("**/*", async (route) => {
|
|
||||||
const url = new URL(route.request().url())
|
|
||||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
|
||||||
const remote = url.origin === serverB
|
|
||||||
const directory = remote ? directoryB : directoryA
|
|
||||||
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
|
||||||
const requestDirectory = url.searchParams.get("directory")
|
|
||||||
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
|
||||||
if (route.request().method() === "POST" && response) {
|
|
||||||
permissionResponses.push({
|
|
||||||
origin: url.origin,
|
|
||||||
directory: requestDirectory ?? undefined,
|
|
||||||
sessionID: response[1]!,
|
|
||||||
permissionID: response[2]!,
|
|
||||||
body: route.request().postDataJSON(),
|
|
||||||
})
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
|
||||||
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
|
||||||
return json(route, { data: [] })
|
|
||||||
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
|
||||||
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
|
||||||
return json(route, { location: { directory }, data: [] })
|
|
||||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
|
||||||
if (url.pathname === "/api/mcp/resource")
|
|
||||||
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
|
||||||
if (url.pathname === "/api/project") {
|
|
||||||
return json(route, [
|
|
||||||
{
|
|
||||||
id: remote ? sessionB.projectID : "project-server-a",
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/project/current")
|
|
||||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
|
||||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
|
||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
|
||||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
|
||||||
return json(route, { data: [], cursor: {} })
|
|
||||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
|
||||||
if (current) return json(route, current)
|
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
|
||||||
if (url.pathname === "/permission") {
|
|
||||||
permissionRequests.push(url.toString())
|
|
||||||
return json(route, [])
|
|
||||||
}
|
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
|
||||||
return json(route, [])
|
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
|
||||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
|
||||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
|
||||||
const project = {
|
|
||||||
id: remote ? sessionB.projectID : "project-server-a",
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
sandboxes: [],
|
|
||||||
}
|
|
||||||
return json(route, url.pathname === "/project" ? [project] : project)
|
|
||||||
}
|
|
||||||
if (url.pathname === "/path")
|
|
||||||
return json(route, {
|
|
||||||
state: directory,
|
|
||||||
config: directory,
|
|
||||||
worktree: directory,
|
|
||||||
directory,
|
|
||||||
home: directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/path")
|
|
||||||
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
|
||||||
if (url.pathname === "/api/vcs")
|
|
||||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
|
||||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
|
||||||
return json(route, {})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function session(id: string, directory: string, title: string) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slug: id,
|
|
||||||
projectID: `project-${id}`,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function provider(id: string) {
|
|
||||||
const name = id === "server-b" ? "Server B" : "Server A"
|
|
||||||
return {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
name: `${name} Provider`,
|
|
||||||
models: {
|
|
||||||
[id]: {
|
|
||||||
id,
|
|
||||||
name: `${name} Model`,
|
|
||||||
family: id,
|
|
||||||
release_date: "2026-01-01",
|
|
||||||
limit: { context: 200_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: [id],
|
|
||||||
default: { providerID: id, modelID: id },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(route: Route, body: unknown, status = 200) {
|
|
||||||
return route.fulfill({
|
|
||||||
status,
|
|
||||||
contentType: "application/json",
|
|
||||||
headers: { "access-control-allow-origin": "*" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sse(route: Route) {
|
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { currentSession } from "../utils/mock-server"
|
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -58,19 +57,15 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||||
return sse(route, url.pathname === "/api/event")
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
if (url.pathname === "/session/status")
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
||||||
if (url.pathname === "/api/session/active")
|
if (url.pathname === "/session") return json(route, [current])
|
||||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
|
||||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
|
||||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
@@ -95,20 +90,7 @@ async function mockServers(page: Page) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
if (url.pathname === "/api/path")
|
|
||||||
return json(route, {
|
|
||||||
state: current.directory,
|
|
||||||
config: current.directory,
|
|
||||||
worktree: current.directory,
|
|
||||||
directory: current.directory,
|
|
||||||
home: current.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
if (url.pathname === "/api/vcs")
|
|
||||||
return json(route, {
|
|
||||||
location: { directory: current.directory },
|
|
||||||
data: { branch: "main", defaultBranch: "main" },
|
|
||||||
})
|
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -122,10 +104,6 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route, current: boolean) {
|
function sse(route: Route) {
|
||||||
return route.fulfill({
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
status: 200,
|
|
||||||
contentType: "text/event-stream",
|
|
||||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
|||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
@@ -144,9 +143,9 @@ async function openReview(page: Page) {
|
|||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
await page.getByRole("tab", { name: "Changes" }).click()
|
||||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
expect(await (await diffResponse).json()).toHaveLength(1)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
|||||||
@@ -91,17 +91,15 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
|
|
||||||
const panel = page.locator("#review-panel")
|
const panel = page.locator("#review-panel")
|
||||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
|
||||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
|
||||||
await expect(sidebar).toBeVisible()
|
await expect(sidebar).toBeVisible()
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebar).toBeHidden()
|
await expect(sidebar).toHaveCount(0)
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
await expect(filter).toBeFocused()
|
await expect(filter).toBeFocused()
|
||||||
@@ -110,7 +108,6 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
|
|
||||||
await panel.getByRole("button", { name: "README.md" }).click()
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
|
||||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
await expect(sidebar).toHaveCount(0)
|
await expect(sidebar).toHaveCount(0)
|
||||||
|
|
||||||
@@ -125,17 +122,12 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
await filter.press("Enter")
|
await filter.press("Enter")
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
|
||||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
|
||||||
await panel.locator("#session-side-panel-review-tab").click()
|
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
|||||||
|
|
||||||
async function selectMode(page: Page, current: string, next: string) {
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
await page.getByRole("button", { name: current }).click()
|
await page.getByRole("button", { name: current }).click()
|
||||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
await page.getByRole("option", { name: next }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(page: Page, file: string) {
|
async function selectFile(page: Page, file: string) {
|
||||||
@@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v1",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
|||||||
await expectSessionTitle(page, titleA)
|
await expectSessionTitle(page, titleA)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
||||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
||||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||||
|
|||||||
@@ -20,12 +20,10 @@ const branchDiffs = [
|
|||||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||||
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
|
||||||
let detailVersion = 1
|
let detailVersion = 1
|
||||||
let detailFailures = 1
|
let detailFailures = 1
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v1",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -57,7 +55,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionStatus: () => sessionStatus,
|
sessionStatus: { [sessionID]: { type: "idle" } },
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
@@ -66,10 +64,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||||
branch: "review-pane-performance",
|
|
||||||
default_branch: "dev",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/vcs/diff**", (route) => {
|
await page.route("**/vcs/diff**", (route) => {
|
||||||
@@ -91,51 +86,15 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route("**/pty*", (route) =>
|
await page.route("**/pty", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: {
|
|
||||||
id: "pty_review_terminal",
|
|
||||||
title: "Terminal 1",
|
|
||||||
command: "cmd.exe",
|
|
||||||
args: [],
|
|
||||||
cwd: directory,
|
|
||||||
status: "running",
|
|
||||||
pid: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_review_terminal*", (route) =>
|
await page.route("**/pty/pty_review_terminal", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: {
|
|
||||||
id: "pty_review_terminal",
|
|
||||||
title: "Terminal 1",
|
|
||||||
command: "cmd.exe",
|
|
||||||
args: [],
|
|
||||||
cwd: directory,
|
|
||||||
status: "running",
|
|
||||||
pid: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -152,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
await expectTree(page, 8, "git-0.ts")
|
await expectTree(page, 8, "git-0.ts")
|
||||||
|
|
||||||
await selectMode(page, "Git changes", "Branch changes")
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||||
await expectTree(page, 2_773, "action.yml")
|
await expectTree(page, 2_773, "action.yml")
|
||||||
@@ -184,7 +143,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||||
await expect(preview).toContainText("after-1")
|
await expect(preview).toContainText("after-1")
|
||||||
detailVersion = 2
|
detailVersion = 2
|
||||||
sessionStatus[sessionID] = { type: "busy" }
|
|
||||||
events.push(statusEvent("busy"))
|
events.push(statusEvent("busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
const refreshedDiff = page.waitForRequest((request) => {
|
const refreshedDiff = page.waitForRequest((request) => {
|
||||||
@@ -194,7 +152,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
sessionStatus[sessionID] = { type: "idle" }
|
|
||||||
events.push(statusEvent("idle"))
|
events.push(statusEvent("idle"))
|
||||||
await refreshedDiff
|
await refreshedDiff
|
||||||
await expect(preview).toContainText("after-2")
|
await expect(preview).toContainText("after-2")
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
|||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/api/path?*", async (route) => {
|
await page.route("**/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/RequestDocks"
|
const directory = "C:/OpenCode/RequestDocks"
|
||||||
@@ -42,8 +41,7 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||||
rejectRequests.push(request.url())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
@@ -65,9 +63,7 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) =>
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||||
request.method() === "POST" &&
|
|
||||||
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
@@ -100,69 +96,8 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
||||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||||
})
|
|
||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
|
||||||
const transport = await installSseTransport(page, {
|
|
||||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
|
||||||
retry: 20,
|
|
||||||
})
|
|
||||||
await mockServer(page, { questions: [] })
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
await transport.waitForConnection()
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
|
|
||||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
|
||||||
const draft = "keep the caret at the end"
|
|
||||||
await editor.fill(draft)
|
|
||||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
|
||||||
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
|
||||||
const cursor = draft.length - 4
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
editor.evaluate((element) => {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
|
||||||
const range = selection.getRangeAt(0).cloneRange()
|
|
||||||
range.selectNodeContents(element)
|
|
||||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
|
||||||
return range.toString().length
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toBe(cursor)
|
|
||||||
await transport.send({
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "question.asked",
|
|
||||||
properties: {
|
|
||||||
id: "question-caret",
|
|
||||||
sessionID,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Continue",
|
|
||||||
question: "Continue?",
|
|
||||||
options: [{ label: "Yes", description: "Continue the session" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
|
||||||
await expect(question).toBeVisible()
|
|
||||||
await expect(editor).toHaveCount(0)
|
|
||||||
|
|
||||||
await transport.send({
|
|
||||||
directory,
|
|
||||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
|
||||||
})
|
|
||||||
await expect(question).toHaveCount(0)
|
|
||||||
await expect(editor).toBeVisible()
|
|
||||||
await page.keyboard.press("x")
|
|
||||||
|
|
||||||
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mockServer(
|
async function mockServer(
|
||||||
@@ -173,7 +108,6 @@ async function mockServer(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
|||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
id,
|
id,
|
||||||
"apply_patch",
|
"patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts"] },
|
{ files: ["src/a.ts"] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
|||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"apply_patch",
|
"patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: files.map((file) => file.filePath) },
|
{ files: files.map((file) => file.filePath) },
|
||||||
{ metadata: { files } },
|
{ metadata: { files } },
|
||||||
|
|||||||
@@ -17,14 +17,12 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const initialPageSize = 20
|
const assistants = Array.from({ length: 14 }, (_, index) =>
|
||||||
const historyPageSize = 200
|
|
||||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
|
||||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
parentID: userID,
|
parentID: userID,
|
||||||
created: 1700000001000 + index * 1_000,
|
created: 1700000001000 + index * 1_000,
|
||||||
completed: index < initialPageSize,
|
completed: index < 13,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const messages = [userMessage(), ...assistants]
|
const messages = [userMessage(), ...assistants]
|
||||||
@@ -48,7 +46,7 @@ const scenarios = [
|
|||||||
test.use({ viewport: { width: 646, height: 1385 } })
|
test.use({ viewport: { width: 646, height: 1385 } })
|
||||||
|
|
||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||||
const pages: { before?: string; limit: number }[] = []
|
const pages: { before?: string; limit: number }[] = []
|
||||||
const roots: { sessionID: string; messageID: string }[] = []
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
@@ -103,51 +101,36 @@ for (const scenario of scenarios) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(
|
||||||
const visibleParts = () => {
|
({ userPartID, lastPartID }) => {
|
||||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||||
const view = viewport?.getBoundingClientRect()
|
const sample = () => {
|
||||||
if (!viewport || !view) return []
|
if (state.armed) {
|
||||||
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
.filter((part) => {
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
const rect = part.getBoundingClientRect()
|
const view = viewport?.getBoundingClientRect()
|
||||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
const visible = (partID: string) => {
|
||||||
})
|
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||||
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
const rect = part?.getBoundingClientRect()
|
||||||
}
|
return (
|
||||||
const state = {
|
!!rect &&
|
||||||
armed: false,
|
!!view &&
|
||||||
hidden: false,
|
rect.width > 0 &&
|
||||||
visibleParts: [] as string[],
|
rect.height > 0 &&
|
||||||
samples: 0,
|
rect.bottom > view.top &&
|
||||||
stop: false,
|
rect.top < view.bottom
|
||||||
arm() {
|
)
|
||||||
state.visibleParts = visibleParts()
|
}
|
||||||
state.armed = true
|
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||||
},
|
state.samples++
|
||||||
}
|
|
||||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
|
||||||
const sample = () => {
|
|
||||||
if (state.armed) {
|
|
||||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
|
||||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
|
||||||
const view = viewport?.getBoundingClientRect()
|
|
||||||
const visible = (partID: string) => {
|
|
||||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
|
||||||
const rect = part?.getBoundingClientRect()
|
|
||||||
return (
|
|
||||||
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
state.hidden = true
|
|
||||||
state.samples++
|
|
||||||
}
|
}
|
||||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
}
|
},
|
||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
{ userPartID, lastPartID },
|
||||||
})
|
)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
@@ -160,28 +143,23 @@ for (const scenario of scenarios) {
|
|||||||
"messages:start:latest",
|
"messages:start:latest",
|
||||||
"messages:end:latest",
|
"messages:end:latest",
|
||||||
`message:${userID}`,
|
`message:${userID}`,
|
||||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
`messages:start:${messages.at(-2)!.info.id}`,
|
||||||
])
|
])
|
||||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__historyRootProbe?: { arm(): void }
|
__historyRootProbe?: { armed: boolean }
|
||||||
}
|
}
|
||||||
).__historyRootProbe!.arm()
|
).__historyRootProbe!.armed = true
|
||||||
})
|
})
|
||||||
await waitForProbeSamples(page, 0)
|
await waitForProbeSamples(page, 0)
|
||||||
expect(await visibleContentHidden(page)).toBe(false)
|
expect(await historyRootHidden(page)).toBe(false)
|
||||||
const beforeHistory = await probeSamples(page)
|
const beforeHistory = await probeSamples(page)
|
||||||
history.resolve()
|
history.resolve()
|
||||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
await waitForProbeSamples(page, beforeHistory)
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
expect(pages).toEqual([
|
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||||
{ before: undefined, limit: initialPageSize },
|
|
||||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
|
||||||
])
|
|
||||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||||
|
|
||||||
const message = messageUpdated(scenario.info)
|
const message = messageUpdated(scenario.info)
|
||||||
@@ -235,7 +213,7 @@ async function waitForProbeSamples(page: Page, after: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleContentHidden(page: Page) {
|
function historyRootHidden(page: Page) {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,23 +32,6 @@ for (const expanded of [false, true]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
|
||||||
const id = "prt_shell_running_command"
|
|
||||||
const command = "sleep 10 && echo done"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
|
||||||
settings: { shellToolPartsExpanded: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
|
||||||
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
|
||||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
|
||||||
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
|
||||||
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
|
||||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
|
||||||
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||||
const reasoningID = "prt_reasoning_hidden"
|
const reasoningID = "prt_reasoning_hidden"
|
||||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
|
|||||||
editPart("prt_edit"),
|
editPart("prt_edit"),
|
||||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||||
patchPart("prt_patch"),
|
patchPart("prt_patch"),
|
||||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
|
||||||
toolPart(
|
toolPart(
|
||||||
"prt_question",
|
"prt_question",
|
||||||
"question",
|
"question",
|
||||||
@@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
|
|||||||
]) {
|
]) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||||
}
|
}
|
||||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||||
@@ -152,10 +150,7 @@ test.describe("session timeline projection", () => {
|
|||||||
parentID: "msg_2000_diff_next_user",
|
parentID: "msg_2000_diff_next_user",
|
||||||
created: 1700000011000,
|
created: 1700000011000,
|
||||||
})
|
})
|
||||||
await setupTimeline(page, {
|
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||||
messages: [user, assistantMessage(), nextUser, nextAssistant],
|
|
||||||
settings: { newLayoutDesigns: false },
|
|
||||||
})
|
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||||
|
|
||||||
@@ -249,7 +244,7 @@ function editPart(id: string) {
|
|||||||
function patchPart(id: string) {
|
function patchPart(id: string) {
|
||||||
return toolPart(
|
return toolPart(
|
||||||
id,
|
id,
|
||||||
"apply_patch",
|
"patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts", "src/b.ts"] },
|
{ files: ["src/a.ts", "src/b.ts"] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "../performance/timeline-stability/fixture"
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||||
const parts = ordinary.map((tool, index) =>
|
const parts = ordinary.map((tool, index) =>
|
||||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||||
)
|
)
|
||||||
@@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
|||||||
error: "The user dismissed this question",
|
error: "The user dismissed this question",
|
||||||
}),
|
}),
|
||||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
|
||||||
)
|
)
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||||
|
|
||||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
|
||||||
for (let index = 0; index < ordinary.length; index++) {
|
for (let index = 0; index < ordinary.length; index++) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||||
}
|
}
|
||||||
@@ -90,7 +88,7 @@ function questionInput() {
|
|||||||
function errorInput(tool: string) {
|
function errorInput(tool: string) {
|
||||||
if (tool === "bash") return { command: "exit 1" }
|
if (tool === "bash") return { command: "exit 1" }
|
||||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
if (tool === "patch") return { files: ["src/error.ts"] }
|
||||||
if (tool === "webfetch") return { url: "https://example.com" }
|
if (tool === "webfetch") return { url: "https://example.com" }
|
||||||
if (tool === "websearch") return { query: "failure" }
|
if (tool === "websearch") return { query: "failure" }
|
||||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
id: "timeline-event-7",
|
||||||
})
|
})
|
||||||
@@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
|
|||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through non-event fetches", async ({ page }) => {
|
test("passes through non-event fetches", async ({ page }) => {
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
|
||||||
const projectID = "proj_todo_dock_navigation"
|
|
||||||
const sourceID = "ses_todo_dock_source"
|
|
||||||
const otherID = "ses_todo_dock_other"
|
|
||||||
const sourceTitle = "Todo dock animation"
|
|
||||||
const otherTitle = "Separate session"
|
|
||||||
|
|
||||||
const activeTodos = [
|
|
||||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
|
||||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
|
||||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
|
||||||
]
|
|
||||||
|
|
||||||
type EventPayload = {
|
|
||||||
directory: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
|
||||||
|
|
||||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000)
|
|
||||||
const events: EventPayload[] = []
|
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
|
||||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
|
||||||
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "todo-dock-navigation",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: {
|
|
||||||
"claude-opus-4-6": {
|
|
||||||
id: "claude-opus-4-6",
|
|
||||||
name: "Claude Opus 4.6",
|
|
||||||
limit: { context: 200_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
|
||||||
},
|
|
||||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
|
||||||
sessionStatus: { [sourceID]: { type: "busy" } },
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
events: () => events.splice(0, 1),
|
|
||||||
eventRetry: 16,
|
|
||||||
sessionStatus: () => sessionStatus,
|
|
||||||
todos: (sessionID) => todos[sessionID] ?? [],
|
|
||||||
})
|
|
||||||
await configurePage(page)
|
|
||||||
|
|
||||||
await page.goto(sessionHref(sourceID))
|
|
||||||
await expectSessionTitle(page, sourceTitle)
|
|
||||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
|
|
||||||
sessionStatus[sourceID] = { type: "busy" }
|
|
||||||
events.push(statusEvent(sourceID, "busy"))
|
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
|
||||||
|
|
||||||
await page.waitForTimeout(700)
|
|
||||||
const opening = sampleDock(page, 1_000)
|
|
||||||
todos[sourceID] = activeTodos
|
|
||||||
events.push(todoEvent(sourceID, activeTodos))
|
|
||||||
await expect(dock).toBeVisible()
|
|
||||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
|
||||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
|
||||||
|
|
||||||
await switchSession(page, otherID, otherTitle)
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
|
|
||||||
const returningOpen = sampleDock(page, 700)
|
|
||||||
await switchSession(page, sourceID, sourceTitle)
|
|
||||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
|
||||||
expect(openSamples.length).toBeGreaterThan(0)
|
|
||||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
|
||||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
|
||||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
|
||||||
|
|
||||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
|
||||||
const closing = sampleDock(page, 1_000)
|
|
||||||
todos[sourceID] = completedTodos
|
|
||||||
events.push(todoEvent(sourceID, completedTodos))
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
|
||||||
todos[sourceID] = []
|
|
||||||
events.push(todoEvent(sourceID, []))
|
|
||||||
|
|
||||||
await switchSession(page, otherID, otherTitle)
|
|
||||||
const returningEmpty = sampleDock(page, 700)
|
|
||||||
await switchSession(page, sourceID, sourceTitle)
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slug: id,
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created, updated: created },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
|
||||||
return {
|
|
||||||
directory,
|
|
||||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
|
||||||
return {
|
|
||||||
directory,
|
|
||||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function configurePage(page: Page) {
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, dirBase64, server, sessionIDs }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
|
||||||
lastProject: { local: directory },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionHref(sessionID: string) {
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
|
||||||
const href = sessionHref(sessionID)
|
|
||||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sampleDock(page: Page, duration: number) {
|
|
||||||
return page.evaluate(async (duration) => {
|
|
||||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
|
||||||
const start = performance.now()
|
|
||||||
while (performance.now() - start < duration) {
|
|
||||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
|
||||||
const clip = dock?.parentElement?.parentElement
|
|
||||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
|
||||||
samples.push({
|
|
||||||
present: !!dock,
|
|
||||||
height: clip?.getBoundingClientRect().height ?? 0,
|
|
||||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
|
||||||
})
|
|
||||||
await new Promise(requestAnimationFrame)
|
|
||||||
}
|
|
||||||
return samples
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
@@ -72,19 +72,16 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
|||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
eventRetry: events ? 16 : undefined,
|
||||||
})
|
})
|
||||||
// The child session resolves by ID but is absent from the session list,
|
// The child session resolves via /session/:id but is absent from the /session list,
|
||||||
// matching a subagent session that has not been loaded into the list cache yet.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
await page.route(
|
||||||
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
(route) =>
|
(route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
||||||
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
|
||||||
cursor: {},
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { currentSession } from "../utils/mock-server"
|
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
const sessionC = session("ses_tab_c", "Tab C session")
|
|
||||||
const unresolvedSessionID = "ses_tab_unresolved"
|
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
@@ -42,34 +39,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
|
||||||
await mockServer(page)
|
|
||||||
await page.addInitScript(
|
|
||||||
({ server, sessionA, unresolved, sessionC }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([
|
|
||||||
{ type: "session", server, sessionId: sessionA },
|
|
||||||
{ type: "session", server, sessionId: unresolved },
|
|
||||||
{ type: "session", server, sessionId: sessionC },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
|
||||||
)
|
|
||||||
|
|
||||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
|
||||||
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
|
||||||
await page.goto(hrefA)
|
|
||||||
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
|
||||||
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Control+Alt+ArrowRight")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
|
||||||
})
|
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -83,29 +52,22 @@ function session(id: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB, sessionC]
|
const sessions = [sessionA, sessionB]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||||
return new Promise(() => {})
|
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
|
||||||
return sse(route)
|
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
if (url.pathname === "/session") return json(route, sessions)
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
|
||||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
|
||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
|
||||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
|
||||||
return json(route, { data: [], cursor: {} })
|
|
||||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||||
|
return json(route, {})
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -127,20 +89,7 @@ async function mockServer(page: Page) {
|
|||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: sessionA.directory,
|
home: sessionA.directory,
|
||||||
})
|
})
|
||||||
if (url.pathname === "/api/path")
|
|
||||||
return json(route, {
|
|
||||||
state: sessionA.directory,
|
|
||||||
config: sessionA.directory,
|
|
||||||
worktree: sessionA.directory,
|
|
||||||
directory: sessionA.directory,
|
|
||||||
home: sessionA.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
if (url.pathname === "/api/vcs")
|
|
||||||
return json(route, {
|
|
||||||
location: { directory: sessionA.directory },
|
|
||||||
data: { branch: "main", defaultBranch: "main" },
|
|
||||||
})
|
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
@@ -7,13 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
|||||||
const projectID = "proj_terminal_composer_focus"
|
const projectID = "proj_terminal_composer_focus"
|
||||||
const sessionID = "ses_terminal_composer_focus"
|
const sessionID = "ses_terminal_composer_focus"
|
||||||
const ptyID = "pty_terminal_composer_focus"
|
const ptyID = "pty_terminal_composer_focus"
|
||||||
const newPtyID = "pty_terminal_composer_focus_new"
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -47,36 +45,29 @@ test.beforeEach(async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/api/pty*", (route) => {
|
await page.route("**/pty", (route) =>
|
||||||
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
@@ -96,132 +87,3 @@ test("routes typing to the composer unless the open terminal is focused", async
|
|||||||
await expect(composer).toBeFocused()
|
await expect(composer).toBeFocused()
|
||||||
await expect(composer).toHaveText("a")
|
await expect(composer).toHaveText("a")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
|
||||||
const ghostty = Promise.withResolvers<void>()
|
|
||||||
const release = Promise.withResolvers<void>()
|
|
||||||
const created = { count: 0 }
|
|
||||||
await page.route("**/api/pty*", (route) => {
|
|
||||||
created.count += 1
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.route(/ghostty-web/, async (route) => {
|
|
||||||
ghostty.resolve()
|
|
||||||
await release.promise
|
|
||||||
await route.continue()
|
|
||||||
})
|
|
||||||
await seedCachedTerminal(page)
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
|
||||||
const terminal = page.locator('[data-component="terminal"]')
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
expect(created.count).toBe(0)
|
|
||||||
await ghostty.promise
|
|
||||||
await composer.click()
|
|
||||||
await expect(composer).toBeFocused()
|
|
||||||
|
|
||||||
release.resolve()
|
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
await page.waitForTimeout(300)
|
|
||||||
await expect(composer).toBeFocused()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
|
||||||
const ghostty = Promise.withResolvers<void>()
|
|
||||||
const release = Promise.withResolvers<void>()
|
|
||||||
await page.route(/ghostty-web/, async (route) => {
|
|
||||||
ghostty.resolve()
|
|
||||||
await release.promise
|
|
||||||
await route.continue()
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
|
||||||
const terminal = page.locator('[data-component="terminal"]')
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
await ghostty.promise
|
|
||||||
await composer.click()
|
|
||||||
await expect(composer).toBeFocused()
|
|
||||||
|
|
||||||
release.resolve()
|
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
await page.waitForTimeout(50)
|
|
||||||
await expect(composer).toBeFocused()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
|
||||||
const created = { count: 0 }
|
|
||||||
await page.route("**/api/pty*", (route) => {
|
|
||||||
created.count += 1
|
|
||||||
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
headers: { "access-control-allow-origin": "*" },
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
|
||||||
const terminal = page.locator('[data-component="terminal"]')
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
await composer.click()
|
|
||||||
await expect(composer).toBeFocused()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New terminal" }).click()
|
|
||||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
function seedCachedTerminal(page: Page) {
|
|
||||||
return page.addInitScript(
|
|
||||||
({ terminalKey, ptyID }) => {
|
|
||||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
terminalKey,
|
|
||||||
JSON.stringify({
|
|
||||||
active: ptyID,
|
|
||||||
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ptyLocation() {
|
|
||||||
return { directory, project: { id: projectID, directory } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ptyInfo(id: string, title: string) {
|
|
||||||
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
|
|||||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -44,53 +43,17 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/api/pty*", (route) =>
|
await page.route("**/pty", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: {
|
|
||||||
id: "pty_hidden_terminal",
|
|
||||||
title: "Terminal 1",
|
|
||||||
command: "cmd.exe",
|
|
||||||
args: [],
|
|
||||||
cwd: directory,
|
|
||||||
status: "running",
|
|
||||||
pid: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: {
|
|
||||||
id: "pty_hidden_terminal",
|
|
||||||
title: "Terminal 1",
|
|
||||||
command: "cmd.exe",
|
|
||||||
args: [],
|
|
||||||
cwd: directory,
|
|
||||||
status: "running",
|
|
||||||
pid: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
location: { directory, project: { id: projectID, directory } },
|
|
||||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
const terminal = page.locator('[data-component="terminal"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
const connection = new URL(connections[0]!)
|
|
||||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
|
||||||
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
|
||||||
expect(connection.searchParams.get("ticket")).toBeNull()
|
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
@@ -66,7 +62,6 @@ async function readProbe(page: Page) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -90,33 +85,26 @@ async function setup(page: Page) {
|
|||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/api/pty*", (route) =>
|
await page.route("**/pty", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
|
||||||
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
|
||||||
const url = new URL(route.request().url())
|
|
||||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||||
})
|
}),
|
||||||
})
|
)
|
||||||
const connections: string[] = []
|
const connections: string[] = []
|
||||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||||
connections.push(ws.url())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -155,11 +143,3 @@ function session(id: string, title: string, created: number) {
|
|||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function ptyLocation() {
|
|
||||||
return { directory, project: { id: projectID, directory } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ptyInfo() {
|
|
||||||
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Timeline Suspense Reproduction</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: #171717;
|
|
||||||
color: #f5f5f5;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="root"></main>
|
|
||||||
<script type="module" src="/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
|
||||||
import { render } from "solid-js/web"
|
|
||||||
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
|
||||||
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
|
||||||
|
|
||||||
const rowCount = 2_000
|
|
||||||
const rowHeight = 40
|
|
||||||
const parameters = new URLSearchParams(location.search)
|
|
||||||
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
|
||||||
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
|
||||||
|
|
||||||
type MutationEvent = {
|
|
||||||
kind: "removed" | "added"
|
|
||||||
callbackTime: number
|
|
||||||
callbackFrame: number
|
|
||||||
routeConnectedInCallback: boolean
|
|
||||||
nativeOffsetInCallback: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Snapshot = {
|
|
||||||
mode: {
|
|
||||||
resource: "baseline" | "guard"
|
|
||||||
reconnect: "baseline" | "candidate"
|
|
||||||
}
|
|
||||||
operation: {
|
|
||||||
sequence: number
|
|
||||||
phase: string
|
|
||||||
time: number
|
|
||||||
frame: number
|
|
||||||
}
|
|
||||||
resourceState: string
|
|
||||||
routeConnected: boolean
|
|
||||||
viewportConnected: boolean
|
|
||||||
viewportOwnedByRoute: boolean
|
|
||||||
sameRoute: boolean
|
|
||||||
sameViewport: boolean
|
|
||||||
sameSurface: boolean
|
|
||||||
sameMountedRows: boolean
|
|
||||||
nativeOffset: number
|
|
||||||
coreOffset: number
|
|
||||||
rangeStart: number
|
|
||||||
rangeEnd: number
|
|
||||||
indexes: number[]
|
|
||||||
domIndexes: number[]
|
|
||||||
logicalSurfaceHeight: number
|
|
||||||
renderedSurfaceHeight: number
|
|
||||||
viewportClientHeight: number
|
|
||||||
viewportScrollHeight: number
|
|
||||||
visibleRows: number
|
|
||||||
minimumRowTop: number
|
|
||||||
domScrollEvents: number
|
|
||||||
lastScrollTrusted: boolean
|
|
||||||
coreOffsetCallbackCalls: number
|
|
||||||
offsetCallbackSources: "observer"[]
|
|
||||||
rectObserverCallbacks: number
|
|
||||||
ignoredDetachedZeroRects: number
|
|
||||||
syntheticScrollDispatches: number
|
|
||||||
mutationEvents: MutationEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
timelineSuspense: {
|
|
||||||
prepare: () => Promise<Snapshot>
|
|
||||||
trigger: () => void
|
|
||||||
resolve: () => void
|
|
||||||
frames: (count?: number) => Promise<void>
|
|
||||||
snapshot: () => Snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [refresh, setRefresh] = createSignal(false)
|
|
||||||
let resolveResource: (() => void) | undefined
|
|
||||||
const [resource] = createResource(
|
|
||||||
refresh,
|
|
||||||
(version) =>
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
resolveResource = () => resolve(`settled-${version}`)
|
|
||||||
}),
|
|
||||||
{ initialValue: "settled" },
|
|
||||||
)
|
|
||||||
|
|
||||||
function Route() {
|
|
||||||
let route: HTMLElement | undefined
|
|
||||||
let viewport: HTMLDivElement | undefined
|
|
||||||
let surface: HTMLDivElement | undefined
|
|
||||||
let initialRoute: HTMLElement | undefined
|
|
||||||
let initialViewport: HTMLDivElement | undefined
|
|
||||||
let initialSurface: HTMLDivElement | undefined
|
|
||||||
let initialRows: HTMLElement[] = []
|
|
||||||
let phase = "mounting"
|
|
||||||
let browserFrame = 0
|
|
||||||
let snapshotSequence = 0
|
|
||||||
let domScrollEvents = 0
|
|
||||||
let lastScrollTrusted = false
|
|
||||||
let coreOffsetCallbackCalls = 0
|
|
||||||
let rectObserverCallbacks = 0
|
|
||||||
let ignoredDetachedZeroRects = 0
|
|
||||||
const offsetCallbackSources: "observer"[] = []
|
|
||||||
const mutationEvents: MutationEvent[] = []
|
|
||||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
||||||
count: rowCount,
|
|
||||||
getScrollElement: () => viewport ?? null,
|
|
||||||
estimateSize: () => rowHeight,
|
|
||||||
initialRect: { width: 900, height: 600 },
|
|
||||||
overscan: 2,
|
|
||||||
observeElementRect: (instance, callback) =>
|
|
||||||
observeElementRect(instance, (rect) => {
|
|
||||||
rectObserverCallbacks++
|
|
||||||
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
|
||||||
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
|
||||||
ignoredDetachedZeroRects++
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callback(rect)
|
|
||||||
}),
|
|
||||||
observeElementOffset: (instance, callback) => {
|
|
||||||
const deliver = (offset: number, isScrolling: boolean) => {
|
|
||||||
coreOffsetCallbackCalls++
|
|
||||||
offsetCallbackSources.push("observer")
|
|
||||||
callback(offset, isScrolling)
|
|
||||||
}
|
|
||||||
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
|
||||||
return observeElementOffset(instance, deliver)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const frames = async (count = 2) => {
|
|
||||||
for (let index = 0; index < count; index++) {
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
|
||||||
const snapshot = (): Snapshot => {
|
|
||||||
const rows = mountedRows()
|
|
||||||
const view = viewport?.getBoundingClientRect()
|
|
||||||
const visibleRows =
|
|
||||||
viewport?.isConnected && view
|
|
||||||
? rows.filter((row) => {
|
|
||||||
const rect = row.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
}).length
|
|
||||||
: 0
|
|
||||||
return {
|
|
||||||
mode: { resource: resourceMode, reconnect: reconnectMode },
|
|
||||||
operation: {
|
|
||||||
sequence: ++snapshotSequence,
|
|
||||||
phase,
|
|
||||||
time: performance.now(),
|
|
||||||
frame: browserFrame,
|
|
||||||
},
|
|
||||||
resourceState: resource.state,
|
|
||||||
routeConnected: route?.isConnected ?? false,
|
|
||||||
viewportConnected: viewport?.isConnected ?? false,
|
|
||||||
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
|
||||||
sameRoute: route === initialRoute,
|
|
||||||
sameViewport: viewport === initialViewport,
|
|
||||||
sameSurface: surface === initialSurface,
|
|
||||||
sameMountedRows:
|
|
||||||
initialRows.length > 0 &&
|
|
||||||
initialRows.length === rows.length &&
|
|
||||||
initialRows.every((row, index) => row === rows[index]),
|
|
||||||
nativeOffset: viewport?.scrollTop ?? -1,
|
|
||||||
coreOffset: virtualizer.scrollOffset ?? -1,
|
|
||||||
rangeStart: virtualizer.range?.startIndex ?? -1,
|
|
||||||
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
|
||||||
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
|
||||||
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
|
||||||
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
|
||||||
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
|
||||||
viewportClientHeight: viewport?.clientHeight ?? -1,
|
|
||||||
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
|
||||||
visibleRows,
|
|
||||||
minimumRowTop:
|
|
||||||
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
|
||||||
domScrollEvents,
|
|
||||||
lastScrollTrusted,
|
|
||||||
coreOffsetCallbackCalls,
|
|
||||||
offsetCallbackSources: [...offsetCallbackSources],
|
|
||||||
rectObserverCallbacks,
|
|
||||||
ignoredDetachedZeroRects,
|
|
||||||
syntheticScrollDispatches: 0,
|
|
||||||
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
|
||||||
const routeRoot = route.parentElement
|
|
||||||
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
|
||||||
initialRoute = route
|
|
||||||
initialViewport = viewport
|
|
||||||
initialSurface = surface
|
|
||||||
viewport.addEventListener("scroll", (event) => {
|
|
||||||
domScrollEvents++
|
|
||||||
lastScrollTrusted = event.isTrusted
|
|
||||||
})
|
|
||||||
const countFrames = () => {
|
|
||||||
browserFrame++
|
|
||||||
requestAnimationFrame(countFrames)
|
|
||||||
}
|
|
||||||
requestAnimationFrame(countFrames)
|
|
||||||
new MutationObserver((records) => {
|
|
||||||
const callbackTime = performance.now()
|
|
||||||
records.forEach((record) => {
|
|
||||||
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
|
||||||
if (node !== route) return
|
|
||||||
phase = "detached"
|
|
||||||
mutationEvents.push({
|
|
||||||
kind: "removed",
|
|
||||||
callbackTime,
|
|
||||||
callbackFrame: browserFrame,
|
|
||||||
routeConnectedInCallback: route.isConnected,
|
|
||||||
nativeOffsetInCallback: viewport.scrollTop,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
|
||||||
if (node !== route) return
|
|
||||||
phase = "reinserted"
|
|
||||||
mutationEvents.push({
|
|
||||||
kind: "added",
|
|
||||||
callbackTime,
|
|
||||||
callbackFrame: browserFrame,
|
|
||||||
routeConnectedInCallback: route.isConnected,
|
|
||||||
nativeOffsetInCallback: viewport.scrollTop,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}).observe(routeRoot, { childList: true })
|
|
||||||
window.timelineSuspense = {
|
|
||||||
prepare: async () => {
|
|
||||||
phase = "preparing"
|
|
||||||
await frames(2)
|
|
||||||
viewport.scrollTop = viewport.scrollHeight
|
|
||||||
await frames(3)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
||||||
await frames(2)
|
|
||||||
initialRows = mountedRows()
|
|
||||||
phase = "prepared"
|
|
||||||
return snapshot()
|
|
||||||
},
|
|
||||||
trigger: () => {
|
|
||||||
phase = "triggering"
|
|
||||||
setRefresh(true)
|
|
||||||
},
|
|
||||||
resolve: () => {
|
|
||||||
if (!resolveResource) throw new Error("Resource is not pending")
|
|
||||||
phase = "resolving"
|
|
||||||
resolveResource()
|
|
||||||
},
|
|
||||||
frames,
|
|
||||||
snapshot,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
|
||||||
<span aria-hidden="true" style={{ display: "none" }}>
|
|
||||||
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
ref={viewport}
|
|
||||||
data-viewport
|
|
||||||
style={{
|
|
||||||
height: "600px",
|
|
||||||
overflow: "auto",
|
|
||||||
"overflow-anchor": "none",
|
|
||||||
position: "relative",
|
|
||||||
background: "#202020",
|
|
||||||
outline: "1px solid #3f3f46",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={surface}
|
|
||||||
data-surface
|
|
||||||
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
|
||||||
>
|
|
||||||
<For each={virtualizer.getVirtualItems()}>
|
|
||||||
{(item) => (
|
|
||||||
<div
|
|
||||||
data-row-index={item.index}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "0",
|
|
||||||
left: "0",
|
|
||||||
width: "100%",
|
|
||||||
height: `${item.size}px`,
|
|
||||||
transform: `translateY(${item.start}px)`,
|
|
||||||
padding: "10px 14px",
|
|
||||||
border: "0 solid #333",
|
|
||||||
"border-bottom-width": "1px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
logical row {item.index}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Suspense>
|
|
||||||
<Route />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render(() => <App />, document.getElementById("root")!)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test"
|
|
||||||
|
|
||||||
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: ".",
|
|
||||||
testMatch: "timeline-suspense.repro.ts",
|
|
||||||
outputDir: "../../test-results/timeline-suspense",
|
|
||||||
fullyParallel: false,
|
|
||||||
workers: 1,
|
|
||||||
retries: 0,
|
|
||||||
reporter: "line",
|
|
||||||
timeout: 30_000,
|
|
||||||
expect: {
|
|
||||||
timeout: 10_000,
|
|
||||||
},
|
|
||||||
webServer: {
|
|
||||||
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
|
||||||
cwd: import.meta.dirname,
|
|
||||||
url: `http://127.0.0.1:${port}`,
|
|
||||||
reuseExistingServer: false,
|
|
||||||
},
|
|
||||||
use: {
|
|
||||||
baseURL: `http://127.0.0.1:${port}`,
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test"
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
page.on("pageerror", (error) => console.error(error))
|
|
||||||
await page.goto("/")
|
|
||||||
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
|
||||||
await page.goto("/?reconnect=candidate")
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
|
||||||
const before = await prepare(page)
|
|
||||||
await triggerBaselineSuspension(page)
|
|
||||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
expect(pending.nativeOffset).toBe(0)
|
|
||||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
|
||||||
expect(pending.indexes).toEqual(before.indexes)
|
|
||||||
expect(pending.sameRoute).toBe(true)
|
|
||||||
expect(pending.sameViewport).toBe(true)
|
|
||||||
expect(pending.sameSurface).toBe(true)
|
|
||||||
expect(pending.sameMountedRows).toBe(true)
|
|
||||||
|
|
||||||
await resolveSuspension(page)
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
|
||||||
await page.waitForTimeout(250)
|
|
||||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
|
||||||
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
expect(after.sameRoute).toBe(true)
|
|
||||||
expect(after.sameViewport).toBe(true)
|
|
||||||
expect(after.sameSurface).toBe(true)
|
|
||||||
expect(after.nativeOffset).toBe(0)
|
|
||||||
expect(after.coreOffset).toBe(0)
|
|
||||||
expect(after.rangeStart).toBeLessThan(10)
|
|
||||||
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
|
||||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
|
||||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
|
||||||
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
|
||||||
expect(after.syntheticScrollDispatches).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const before = await prepare(page)
|
|
||||||
const beforeRows = before.domIndexes
|
|
||||||
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
|
||||||
expect(before.logicalSurfaceHeight).toBe(80_000)
|
|
||||||
expect(before.renderedSurfaceHeight).toBe(80_000)
|
|
||||||
expect(before.viewportClientHeight).toBe(600)
|
|
||||||
expect(before.viewportScrollHeight).toBe(80_000)
|
|
||||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
|
||||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
|
||||||
expect(before.visibleRows).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
await triggerBaselineSuspension(page)
|
|
||||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
expect(pending.resourceState).toBe("refreshing")
|
|
||||||
expect(pending.routeConnected).toBe(false)
|
|
||||||
expect(pending.viewportConnected).toBe(false)
|
|
||||||
expect(pending.viewportOwnedByRoute).toBe(true)
|
|
||||||
expect(pending.nativeOffset).toBe(0)
|
|
||||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
|
||||||
expect(pending.rangeStart).toBe(before.rangeStart)
|
|
||||||
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
|
||||||
expect(pending.indexes).toEqual(before.indexes)
|
|
||||||
expect(pending.domIndexes).toEqual(beforeRows)
|
|
||||||
expect(pending.sameMountedRows).toBe(true)
|
|
||||||
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
|
||||||
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
|
||||||
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
|
||||||
expect(pending.mutationEvents).toHaveLength(1)
|
|
||||||
expect(pending.mutationEvents[0]).toMatchObject({
|
|
||||||
kind: "removed",
|
|
||||||
routeConnectedInCallback: false,
|
|
||||||
nativeOffsetInCallback: 0,
|
|
||||||
})
|
|
||||||
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
|
||||||
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
|
||||||
|
|
||||||
const after = await resolveSuspension(page)
|
|
||||||
expect(after.resourceState).toBe("ready")
|
|
||||||
expect(after.routeConnected).toBe(true)
|
|
||||||
expect(after.viewportConnected).toBe(true)
|
|
||||||
expect(after.viewportOwnedByRoute).toBe(true)
|
|
||||||
expect(after.sameRoute).toBe(true)
|
|
||||||
expect(after.sameViewport).toBe(true)
|
|
||||||
expect(after.sameSurface).toBe(true)
|
|
||||||
expect(after.sameMountedRows).toBe(true)
|
|
||||||
expect(after.nativeOffset).toBe(0)
|
|
||||||
expect(after.coreOffset).toBe(before.coreOffset)
|
|
||||||
expect(after.rangeStart).toBe(before.rangeStart)
|
|
||||||
expect(after.rangeEnd).toBe(before.rangeEnd)
|
|
||||||
expect(after.indexes).toEqual(before.indexes)
|
|
||||||
expect(after.domIndexes).toEqual(beforeRows)
|
|
||||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
|
||||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
|
||||||
expect(after.mutationEvents).toHaveLength(2)
|
|
||||||
expect(after.mutationEvents[1]).toMatchObject({
|
|
||||||
kind: "added",
|
|
||||||
routeConnectedInCallback: true,
|
|
||||||
nativeOffsetInCallback: 0,
|
|
||||||
})
|
|
||||||
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
|
||||||
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
|
||||||
expect(after.visibleRows).toBe(0)
|
|
||||||
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
|
||||||
expect(after.syntheticScrollDispatches).toBe(0)
|
|
||||||
|
|
||||||
await page.locator("[data-viewport]").hover()
|
|
||||||
await page.mouse.wheel(0, 80)
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
page.evaluate(() => {
|
|
||||||
const value = window.timelineSuspense.snapshot()
|
|
||||||
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
|
||||||
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
|
||||||
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
|
||||||
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
|
||||||
expect(recovered.lastScrollTrusted).toBe(true)
|
|
||||||
expect(recovered.rangeStart).toBeLessThan(10)
|
|
||||||
expect(recovered.visibleRows).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
|
||||||
await page.goto("/?resource=guard")
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
|
||||||
const before = await prepare(page)
|
|
||||||
|
|
||||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
|
||||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
|
||||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
expect(pending.routeConnected).toBe(true)
|
|
||||||
expect(pending.mutationEvents).toEqual([])
|
|
||||||
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
|
||||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
|
||||||
expect(pending.visibleRows).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const after = await resolveSuspension(page)
|
|
||||||
expect(after.routeConnected).toBe(true)
|
|
||||||
expect(after.nativeOffset).toBe(before.nativeOffset)
|
|
||||||
expect(after.coreOffset).toBe(before.coreOffset)
|
|
||||||
expect(after.visibleRows).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function prepare(page: Page) {
|
|
||||||
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
|
||||||
expect(before.routeConnected).toBe(true)
|
|
||||||
expect(before.viewportConnected).toBe(true)
|
|
||||||
expect(before.viewportOwnedByRoute).toBe(true)
|
|
||||||
expect(before.sameMountedRows).toBe(true)
|
|
||||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
|
||||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
|
||||||
return before
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerBaselineSuspension(page: Page) {
|
|
||||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
|
||||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSuspension(page: Page) {
|
|
||||||
await page.evaluate(() => window.timelineSuspense.resolve())
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
|
||||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
|
||||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
|
||||||
return page.evaluate(() => window.timelineSuspense.snapshot())
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnostic(value: unknown) {
|
|
||||||
return JSON.stringify(value, null, 2)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vite"
|
|
||||||
import solid from "vite-plugin-solid"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root: import.meta.dirname,
|
|
||||||
plugins: [solid()],
|
|
||||||
})
|
|
||||||
@@ -120,7 +120,7 @@ function toolPart(
|
|||||||
outputLength = 160,
|
outputLength = 160,
|
||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
tool === "apply_patch"
|
tool === "patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||||
@@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
|||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|||||||
@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
"./performance/timeline-stability/fixture.test.ts",
|
"./performance/timeline-stability/fixture.test.ts",
|
||||||
"./performance/timeline-stability/fixture.ts",
|
"./performance/timeline-stability/fixture.ts",
|
||||||
"./performance/unit/visual-stability.test.ts",
|
"./performance/unit/visual-stability.test.ts",
|
||||||
"./reproduction/timeline-suspense/**/*.ts",
|
|
||||||
"./reproduction/timeline-suspense/**/*.tsx",
|
|
||||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
|
||||||
"./regression/new-session-panel-corner.spec.ts",
|
"./regression/new-session-panel-corner.spec.ts",
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
"./regression/session-timeline-context-resize.spec.ts",
|
||||||
"./utils/**/*.ts"
|
"./utils/**/*.ts"
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
import { expectAppVisible } from "../utils/waits"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/NewProject"
|
|
||||||
|
|
||||||
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
|
||||||
let connectedGo = false
|
|
||||||
let pendingGo = false
|
|
||||||
const connections: Array<{ integrationID: string; body: unknown }> = []
|
|
||||||
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: "proj_model_selection_flow",
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "NewProject",
|
|
||||||
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: () => ({
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: {
|
|
||||||
"free-model": {
|
|
||||||
id: "free-model",
|
|
||||||
name: "Free Model",
|
|
||||||
cost: { input: 0, output: 0 },
|
|
||||||
limit: { context: 200_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "opencode-go",
|
|
||||||
name: "OpenCode Go",
|
|
||||||
models: {
|
|
||||||
"go-model-1": {
|
|
||||||
id: "go-model-1",
|
|
||||||
name: "Go Model 1",
|
|
||||||
cost: { input: 1, output: 1 },
|
|
||||||
limit: { context: 200_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "free-model" },
|
|
||||||
}),
|
|
||||||
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
|
||||||
onConnectKey: (input) => {
|
|
||||||
connections.push(input)
|
|
||||||
if (input.integrationID === "opencode-go") pendingGo = true
|
|
||||||
},
|
|
||||||
onInstanceDispose: () => {
|
|
||||||
if (pendingGo) connectedGo = true
|
|
||||||
},
|
|
||||||
sessions: [],
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
fileList: (path) =>
|
|
||||||
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
|
||||||
findFiles: () => ["NewProject"],
|
|
||||||
})
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto("/")
|
|
||||||
const addProject = page.locator('[data-action="home-add-project-row"]')
|
|
||||||
await expectAppVisible(addProject)
|
|
||||||
await addProject.click()
|
|
||||||
await page.locator("[data-directory-path]").click()
|
|
||||||
|
|
||||||
await page.locator('[data-action="home-new-session"]').click()
|
|
||||||
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
|
||||||
|
|
||||||
const modelControl = page.locator('[data-action="prompt-model"]')
|
|
||||||
await modelControl.click()
|
|
||||||
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
|
||||||
|
|
||||||
await page.locator('[data-provider-id="opencode-go"]').click()
|
|
||||||
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
|
||||||
await page.locator('[data-action="provider-connect-submit"]').click()
|
|
||||||
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
|
||||||
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
|
||||||
|
|
||||||
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
|
||||||
await modelControl.click()
|
|
||||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
|
||||||
await expect(goModel).toBeVisible()
|
|
||||||
await goModel.click()
|
|
||||||
|
|
||||||
await expect(modelControl).toContainText("Go Model 1")
|
|
||||||
})
|
|
||||||
@@ -4,11 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
|||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
protocol?: "v1" | "v2"
|
provider: unknown
|
||||||
provider: unknown | (() => unknown)
|
|
||||||
integrationMethods?: Record<string, unknown[]>
|
|
||||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
|
||||||
onInstanceDispose?: () => void
|
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
@@ -21,19 +17,19 @@ export interface MockServerConfig {
|
|||||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
todos?: (sessionID: string) => unknown[]
|
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
sessionStatus?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
|
"/provider": config.provider,
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
config: config.directory,
|
config: config.directory,
|
||||||
@@ -57,47 +53,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
const events = config.events?.()
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
return sse(
|
|
||||||
route,
|
|
||||||
path === "/api/event"
|
|
||||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
|
||||||
: [
|
|
||||||
...(path === "/global/event"
|
|
||||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
|
||||||
: []),
|
|
||||||
...(events ?? []),
|
|
||||||
],
|
|
||||||
config.eventRetry,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (path === "/global/health")
|
|
||||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
|
||||||
if (path === "/api/health" && config.protocol === "v2")
|
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
if (path === "/provider")
|
|
||||||
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
|
||||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
|
||||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
|
||||||
if (legacyAuth && route.request().method() === "PUT") {
|
|
||||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
|
||||||
config.onInstanceDispose?.()
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/session/status")
|
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
||||||
return json(
|
|
||||||
route,
|
|
||||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
|
||||||
)
|
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (path === "/file" && config.fileList)
|
if (path === "/file" && config.fileList)
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
@@ -120,138 +83,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
},
|
},
|
||||||
data: [],
|
data: [],
|
||||||
})
|
})
|
||||||
if (path === "/api/agent")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "build",
|
|
||||||
name: "Build",
|
|
||||||
mode: "primary",
|
|
||||||
hidden: false,
|
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
|
||||||
permissions: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/mcp/resource")
|
|
||||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
|
||||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
|
||||||
if (integration && route.request().method() === "GET")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
|
||||||
})
|
|
||||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
|
||||||
if (integrationConnect && route.request().method() === "POST") {
|
|
||||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (path === "/api/project") return json(route, [config.project])
|
|
||||||
if (path === "/api/project/current")
|
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
|
||||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
|
||||||
if (path === "/api/path")
|
|
||||||
return json(route, {
|
|
||||||
state: config.directory,
|
|
||||||
config: config.directory,
|
|
||||||
worktree: config.directory,
|
|
||||||
directory: config.directory,
|
|
||||||
home: "C:/OpenCode",
|
|
||||||
})
|
|
||||||
if (path === "/api/permission/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
|
||||||
currentPermission,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
if (path === "/api/question/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
|
||||||
})
|
|
||||||
if (path === "/api/vcs")
|
|
||||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
|
||||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
|
||||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
|
||||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
|
||||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
if (path === "/api/session") {
|
|
||||||
const directory = url.searchParams.get("directory")
|
|
||||||
const parentID = url.searchParams.get("parentID")
|
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
|
||||||
const sessions = config.sessions
|
|
||||||
.filter((session) => !directory || session.directory === directory)
|
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
|
||||||
.filter((session) => {
|
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
|
||||||
return (
|
|
||||||
!search ||
|
|
||||||
String(session.title ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(search)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
|
||||||
const data = ordered.slice(offset, offset + limit)
|
|
||||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
|
||||||
return json(route, {
|
|
||||||
data: data.map((session) => currentSession(session, config.directory)),
|
|
||||||
cursor: { next },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (path === "/api/session/active") {
|
|
||||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
|
||||||
return json(route, {
|
|
||||||
data: Object.fromEntries(
|
|
||||||
Object.entries(statuses).flatMap(([id, status]) =>
|
|
||||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
|
||||||
route.request().method() === "POST"
|
|
||||||
) {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
||||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
|
||||||
if (currentSessionMatch) {
|
|
||||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
|
||||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
|
||||||
return json(route, {
|
|
||||||
data: currentSession(session, config.directory),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||||
@@ -270,28 +105,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, message)
|
return json(route, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
|
||||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
|
||||||
if (currentMessagesMatch) {
|
|
||||||
const token = url.searchParams.get("cursor") ?? undefined
|
|
||||||
const before = token ? cursors.get(token) : undefined
|
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
|
||||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
|
||||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
|
||||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
|
||||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
|
||||||
return json(route, {
|
|
||||||
data: pageData.items.map(currentMessage).reverse(),
|
|
||||||
cursor: { next: cursor },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
const token = url.searchParams.get("before") ?? undefined
|
||||||
@@ -314,115 +129,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function location(config: MockServerConfig) {
|
|
||||||
return {
|
|
||||||
directory: config.directory,
|
|
||||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPermission(value: unknown) {
|
|
||||||
const permission = value as Record<string, unknown>
|
|
||||||
if (permission.action) return permission
|
|
||||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
|
||||||
return {
|
|
||||||
id: permission.id,
|
|
||||||
sessionID: permission.sessionID,
|
|
||||||
action: permission.permission,
|
|
||||||
resources: permission.patterns ?? [],
|
|
||||||
save: permission.always,
|
|
||||||
metadata: permission.metadata,
|
|
||||||
source:
|
|
||||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
parentID: session.parentID,
|
|
||||||
projectID: session.projectID ?? "project",
|
|
||||||
agent: session.agent ?? "build",
|
|
||||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
|
||||||
cost: session.cost ?? 0,
|
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: {
|
|
||||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
|
||||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
|
||||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
|
||||||
? { archived: session.time.archived }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
title: session.title ?? session.id,
|
|
||||||
location: {
|
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
|
||||||
},
|
|
||||||
subpath: session.path,
|
|
||||||
revert: session.revert,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMessage(value: unknown) {
|
|
||||||
const item = value as {
|
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
|
||||||
parts: Array<Record<string, unknown> & { type: string }>
|
|
||||||
}
|
|
||||||
if (item.info.role === "user") {
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "user",
|
|
||||||
time: item.info.time,
|
|
||||||
text: item.parts
|
|
||||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
||||||
.join("\n"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "assistant",
|
|
||||||
time: item.info.time,
|
|
||||||
agent: item.info.agent ?? "build",
|
|
||||||
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
|
||||||
cost: item.info.cost,
|
|
||||||
tokens: item.info.tokens,
|
|
||||||
error: item.info.error,
|
|
||||||
content: item.parts.flatMap<unknown>((part) => {
|
|
||||||
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
|
||||||
if (part.type !== "tool") return []
|
|
||||||
const state = part.state as Record<string, unknown>
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: part.id,
|
|
||||||
name: part.tool,
|
|
||||||
time: state.time ?? { created: item.info.time.created },
|
|
||||||
state:
|
|
||||||
state.status === "pending"
|
|
||||||
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
|
||||||
: state.status === "completed"
|
|
||||||
? {
|
|
||||||
status: "completed",
|
|
||||||
input: state.input ?? {},
|
|
||||||
structured: state.metadata ?? {},
|
|
||||||
content: [{ type: "text", text: state.output ?? "" }],
|
|
||||||
}
|
|
||||||
: state.status === "error"
|
|
||||||
? {
|
|
||||||
status: "error",
|
|
||||||
input: state.input ?? {},
|
|
||||||
structured: state.metadata ?? {},
|
|
||||||
content: [],
|
|
||||||
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
|
||||||
}
|
|
||||||
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status,
|
status,
|
||||||
@@ -443,18 +149,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
|||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentEvent(input: unknown) {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event" | "/api/event"
|
path: "/global/event" | "/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -93,21 +93,6 @@ export async function installSseTransport<T>(
|
|||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].join("")
|
||||||
const currentEvent = (input: unknown) => {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location:
|
|
||||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const acknowledge = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
@@ -155,13 +140,15 @@ export async function installSseTransport<T>(
|
|||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => {
|
const encoded = input.deliveries.map((delivery) => ({
|
||||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
delivery,
|
||||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||||
})
|
}))
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
const bytes = encoder.encode(
|
||||||
|
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||||
|
)
|
||||||
connection.controller.enqueue(bytes)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
}
|
}
|
||||||
@@ -174,11 +161,8 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (
|
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||||
url.origin !== server ||
|
return originalFetch(input, init)
|
||||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
|
||||||
)
|
|
||||||
return originalFetch(request)
|
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
@@ -193,18 +177,6 @@ export async function installSseTransport<T>(
|
|||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
if (url.pathname === "/api/event")
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
|
||||||
)
|
|
||||||
if (url.pathname === "/global/event")
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(
|
|
||||||
frame({
|
|
||||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.12",
|
"version": "1.17.18",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:browser",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -53,7 +53,6 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -82,7 +81,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -37,14 +37,6 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe("SerializeAddon", () => {
|
||||||
test("preserves color scheme reporting mode", async () => {
|
|
||||||
const { term, addon } = createTerminal()
|
|
||||||
await writeAndWait(term, "\x1b[?2031h")
|
|
||||||
|
|
||||||
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
|
|
||||||
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|||||||
@@ -89,13 +89,6 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
|||||||
return { active, normal, alternate }
|
return { active, normal, alternate }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTerminalMode = (value: ITerminalCore, mode: number) => {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
const terminal = value.wasmTerm
|
|
||||||
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
|
|
||||||
return terminal.getMode(mode) === true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
|
let content = options?.range
|
||||||
content += options?.range
|
|
||||||
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
||||||
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||||
|
|
||||||
|
|||||||
+29
-54
@@ -9,16 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
|
|||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import {
|
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
type BaseRouterProps,
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Router,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "@solidjs/router"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
@@ -38,7 +29,6 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
@@ -67,8 +57,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
|||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome } from "@/pages/home"
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
@@ -164,7 +153,8 @@ function LegacyTargetSessionRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||||
|
// Notification/Models + the visual Layout) for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
@@ -217,7 +207,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<ModelsProvider directory={directory}>
|
<DraftServerScopedProviders directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
@@ -225,7 +215,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</ModelsProvider>
|
</DraftServerScopedProviders>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -237,30 +227,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
|
||||||
const global = useGlobal()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (settings.general.newLayoutDesigns()) return
|
|
||||||
const current = server.current
|
|
||||||
if (!current) return
|
|
||||||
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
|
||||||
if (protocol !== "v2") return
|
|
||||||
const next = global.servers.list().find((s) => {
|
|
||||||
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
|
||||||
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
|
||||||
})
|
|
||||||
if (!next) return
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -343,21 +309,24 @@ function DesktopCommands() {
|
|||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
|
sessionID?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<LayoutProvider>
|
<PermissionProvider directory={props.directory}>
|
||||||
{props.serverScoped}
|
<LayoutProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
{props.serverScoped}
|
||||||
</LayoutProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
@@ -373,6 +342,14 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||||
|
return (
|
||||||
|
<PermissionProvider directory={props.directory}>
|
||||||
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -582,15 +559,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<PermissionProvider>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
<ServerShell>
|
||||||
<ServerShell>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
</Show>
|
||||||
</Show>
|
</ServerShell>
|
||||||
</ServerShell>
|
</NotificationProvider>
|
||||||
</NotificationProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -1,20 +1,17 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,8 +24,6 @@ export type CommandPaletteEntry = {
|
|||||||
path?: string
|
path?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
server?: ServerConnection.Key
|
|
||||||
project?: LocalProject
|
|
||||||
archived?: number
|
archived?: number
|
||||||
updated?: number
|
updated?: number
|
||||||
}
|
}
|
||||||
@@ -80,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
|||||||
|
|
||||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
const serverSDK = useServerSDK()()
|
const serverSDK = useServerSDK()()
|
||||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
const serverSync = useServerSync()
|
||||||
const appTabs = useTabs()
|
const { params, tabs } = useSessionLayout()
|
||||||
const { tabs: sessionTabs } = useSessionLayout()
|
|
||||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const filesOnly = () => props.filesOnly?.() ?? false
|
const filesOnly = () => props.filesOnly?.() ?? false
|
||||||
|
|
||||||
const allowedCommands = createMemo(() => {
|
const allowedCommands = createMemo(() => {
|
||||||
if (filesOnly()) return []
|
if (filesOnly()) return []
|
||||||
return commandPaletteOptions(command.options)
|
return command.options.filter(
|
||||||
|
(option) =>
|
||||||
|
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
const commandEntries = createMemo(() => {
|
const commandEntries = createMemo(() => {
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
return allowedCommands().map((option) => createCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
const preferredCommandEntries = createMemo(() => {
|
const preferredCommandEntries = createMemo(() => {
|
||||||
const all = allowedCommands()
|
const all = allowedCommands()
|
||||||
@@ -107,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs: sessionTabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
@@ -142,11 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessions = createServerSessionEntries({
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
server: ServerConnection.key(serverSDK.server),
|
const project = createMemo(() => {
|
||||||
opened: serverCtx.projects.list,
|
const directory = projectDirectory()
|
||||||
stored: () => serverCtx.sync.data.project,
|
if (!directory) return undefined
|
||||||
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||||
|
})
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
const current = project()
|
||||||
|
if (!current) return directory ? [directory] : []
|
||||||
|
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||||
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
|
return dirs
|
||||||
|
})
|
||||||
|
const homedir = createMemo(() => serverSync().data.path.home)
|
||||||
|
const sessions = createSessionEntries({
|
||||||
|
workspaces,
|
||||||
|
label: (directory) => {
|
||||||
|
const current = project()
|
||||||
|
const kind =
|
||||||
|
current && directory === current.worktree
|
||||||
|
? language.t("workspace.type.local")
|
||||||
|
: language.t("workspace.type.sandbox")
|
||||||
|
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
const home = homedir()
|
||||||
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
|
return `${kind} : ${name || path}`
|
||||||
|
},
|
||||||
|
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
@@ -168,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (item.type === "session") {
|
if (item.type === "session") {
|
||||||
if (!item.sessionID || !item.server) return
|
if (!item.directory || !item.sessionID) return
|
||||||
const directory = item.project?.worktree ?? item.directory
|
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||||
if (directory) {
|
|
||||||
serverCtx.projects.open(directory)
|
|
||||||
serverCtx.projects.touch(directory)
|
|
||||||
}
|
|
||||||
const tab = appTabs.addSessionTab({
|
|
||||||
server: item.server,
|
|
||||||
sessionId: item.sessionID,
|
|
||||||
})
|
|
||||||
appTabs.select(tab)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!item.path) return
|
if (!item.path) return
|
||||||
@@ -204,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||||
return {
|
return {
|
||||||
id: "command:" + option.id,
|
id: "command:" + option.id,
|
||||||
type: "command",
|
type: "command",
|
||||||
@@ -216,66 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
server: ServerConnection.Key
|
workspaces: () => string[]
|
||||||
opened: () => LocalProject[]
|
label: (directory: string) => string
|
||||||
stored: () => Project[]
|
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||||
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
let abort: AbortController | undefined
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||||
|
cached: CommandPaletteEntry[] | undefined
|
||||||
|
} = { token: 0, inflight: undefined, cached: undefined }
|
||||||
|
|
||||||
onCleanup(() => abort?.abort())
|
return (text: string) => {
|
||||||
|
if (!text.trim()) {
|
||||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
state.token += 1
|
||||||
const search = text.trim()
|
state.inflight = undefined
|
||||||
if (!search) {
|
state.cached = undefined
|
||||||
abort?.abort()
|
return [] as CommandPaletteEntry[]
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
abort?.abort()
|
if (state.cached) return state.cached
|
||||||
const current = new AbortController()
|
if (state.inflight) return state.inflight
|
||||||
abort = current
|
|
||||||
await new Promise<void>((resolve) => {
|
const current = state.token
|
||||||
const timer = setTimeout(resolve, 100)
|
const dirs = props.workspaces()
|
||||||
current.signal.addEventListener(
|
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||||
"abort",
|
|
||||||
() => {
|
state.inflight = Promise.all(
|
||||||
clearTimeout(timer)
|
dirs.map((directory) => {
|
||||||
resolve()
|
const description = props.label(directory)
|
||||||
},
|
return props
|
||||||
{ once: true },
|
.load(directory)
|
||||||
)
|
.then((result) =>
|
||||||
})
|
(result.data ?? [])
|
||||||
if (current.signal.aborted) return []
|
.filter((session) => !!session?.id)
|
||||||
const opened = props.opened()
|
.map((session) => ({
|
||||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
id: session.id,
|
||||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
title: session.title ?? props.untitled(),
|
||||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
description,
|
||||||
return props
|
directory,
|
||||||
.load(search, current.signal)
|
archived: session.time?.archived,
|
||||||
.then((result) =>
|
updated: session.time?.updated,
|
||||||
result.data
|
})),
|
||||||
.map(normalizeSessionInfo)
|
)
|
||||||
.filter((session) => !session.time.archived)
|
.catch(() => [] as SessionEntryInput[])
|
||||||
.map((session) => {
|
}),
|
||||||
const project =
|
)
|
||||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
.then((results) => {
|
||||||
return {
|
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||||
id: `session:${props.server}:${session.id}`,
|
const seen = new Set<string>()
|
||||||
type: "session" as const,
|
const next = results
|
||||||
title: session.title || props.untitled(),
|
.flat()
|
||||||
description: project ? displayName(project) : getFilename(session.directory),
|
.filter((item) => {
|
||||||
category: props.category(),
|
const key = `${item.directory}:${item.id}`
|
||||||
directory: session.directory,
|
if (seen.has(key)) return false
|
||||||
sessionID: session.id,
|
seen.add(key)
|
||||||
server: props.server,
|
return true
|
||||||
project,
|
})
|
||||||
updated: session.time.updated,
|
.map((item) => createSessionEntry(item, props.category()))
|
||||||
}
|
state.cached = next
|
||||||
}),
|
return next
|
||||||
)
|
})
|
||||||
.catch(() => [] as CommandPaletteEntry[])
|
.catch(() => [] as CommandPaletteEntry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionEntryInput = {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
memory?: {
|
memory?: {
|
||||||
@@ -66,7 +65,7 @@ function Cell(props: {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center": true,
|
"flex min-w-0 items-center": true,
|
||||||
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"justify-center text-center": !props.inline,
|
"justify-center text-center": !props.inline,
|
||||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
"col-span-2": !!props.wide && !props.inline,
|
"col-span-2": !!props.wide && !props.inline,
|
||||||
@@ -74,29 +73,21 @@ function Cell(props: {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0": true,
|
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
|
||||||
"flex-col items-center": !props.inline,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{props.label}
|
||||||
classList={{
|
</div>
|
||||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
<div
|
||||||
}}
|
classList={{
|
||||||
>
|
"uppercase leading-none font-bold tabular-nums": true,
|
||||||
{props.label}
|
"text-[11px]": !!props.inline,
|
||||||
</div>
|
"text-[13px] sm:text-[14px]": !props.inline,
|
||||||
<div
|
"text-text-on-critical-base": !!props.bad,
|
||||||
classList={{
|
"opacity-70": !!props.dim,
|
||||||
"uppercase leading-none font-bold tabular-nums": true,
|
}}
|
||||||
"text-[11px]": !!props.inline,
|
>
|
||||||
"text-[13px] sm:text-[14px]": !props.inline,
|
{props.value}
|
||||||
"text-text-on-critical-base": !!props.bad,
|
|
||||||
"opacity-70": !!props.dim,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,55 +107,8 @@ function Cell(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
|
||||||
const content = () => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Force focus styles on all interactive elements"
|
|
||||||
aria-pressed={props.active}
|
|
||||||
classList={{
|
|
||||||
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
|
||||||
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
|
||||||
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
|
||||||
"bg-surface-raised-base text-text-strong": props.active,
|
|
||||||
}}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
flex: true,
|
|
||||||
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
|
||||||
"flex-col items-center": !props.inline,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
|
||||||
<span
|
|
||||||
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
|
|
||||||
>
|
|
||||||
{props.active ? "ON" : "OFF"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (props.inline) {
|
|
||||||
return (
|
|
||||||
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
|
||||||
{content()}
|
|
||||||
</TooltipV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
|
||||||
{content()}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -172,7 +116,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
delay: undefined as number | undefined,
|
delay: undefined as number | undefined,
|
||||||
fps: undefined as number | undefined,
|
fps: undefined as number | undefined,
|
||||||
gap: undefined as number | undefined,
|
gap: undefined as number | undefined,
|
||||||
focus: false,
|
|
||||||
heap: {
|
heap: {
|
||||||
limit: undefined as number | undefined,
|
limit: undefined as number | undefined,
|
||||||
used: undefined as number | undefined,
|
used: undefined as number | undefined,
|
||||||
@@ -199,16 +142,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
const toggleFocus = async () => {
|
|
||||||
if (!platform.setForceFocus) return
|
|
||||||
const enabled = !state.focus
|
|
||||||
await platform.setForceFocus(enabled)
|
|
||||||
setState("focus", enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let start = 0
|
let start = 0
|
||||||
@@ -557,11 +490,8 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide={!platform.setForceFocus}
|
wide
|
||||||
/>
|
/>
|
||||||
{platform.setForceFocus && (
|
|
||||||
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
import { formatKeybindParts } from "@/context/command"
|
||||||
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
import {
|
import {
|
||||||
createCommandPaletteCommandEntry,
|
|
||||||
createCommandPaletteFileEntry,
|
createCommandPaletteFileEntry,
|
||||||
createCommandPaletteModel,
|
createCommandPaletteModel,
|
||||||
createServerSessionEntries,
|
|
||||||
uniqueCommandPaletteEntries,
|
uniqueCommandPaletteEntries,
|
||||||
type CommandPaletteEntry,
|
type CommandPaletteEntry,
|
||||||
} from "./command-palette"
|
} from "./command-palette"
|
||||||
@@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
|||||||
|
|
||||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
const palette = createCommandPaletteModel(props)
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
|
||||||
const loadItems = async (text: string) => {
|
const loadItems = async (text: string) => {
|
||||||
const q = text.trim()
|
const q = text.trim()
|
||||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
@@ -45,104 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
const category = palette.language.t("palette.group.files")
|
const category = palette.language.t("palette.group.files")
|
||||||
return [
|
return [
|
||||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||||
...nextSessions,
|
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||||
<CommandPaletteView
|
|
||||||
placeholder={palette.language.t("palette.search.placeholder")}
|
|
||||||
loadItems={loadItems}
|
|
||||||
highlight={palette.highlight}
|
|
||||||
select={palette.select}
|
|
||||||
close={palette.close}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogHomeCommandPaletteV2(props: {
|
|
||||||
server: ServerConnection.Any
|
|
||||||
onSelectSession: (entry: CommandPaletteEntry) => void
|
|
||||||
}) {
|
|
||||||
const command = useCommand()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverCtx = global.ensureServerCtx(props.server)
|
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
|
||||||
const commandEntries = createMemo(() => {
|
|
||||||
const category = language.t("palette.group.commands")
|
|
||||||
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
const sessions = createServerSessionEntries({
|
|
||||||
server: ServerConnection.key(props.server),
|
|
||||||
opened: serverCtx.projects.list,
|
|
||||||
stored: () => serverCtx.sync.data.project,
|
|
||||||
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
|
||||||
category: () => language.t("command.category.session"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
|
||||||
state.cleanup?.()
|
|
||||||
state.cleanup = undefined
|
|
||||||
if (item?.type !== "command") return
|
|
||||||
state.cleanup = item.option?.onHighlight?.()
|
|
||||||
}
|
|
||||||
const select = (item: CommandPaletteEntry | undefined) => {
|
|
||||||
if (!item) return
|
|
||||||
state.committed = true
|
|
||||||
state.cleanup = undefined
|
|
||||||
dialog.close()
|
|
||||||
if (item.type === "command") {
|
|
||||||
item.option?.onSelect?.("palette")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (item.type === "session") props.onSelectSession(item)
|
|
||||||
}
|
|
||||||
const loadItems = async (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) return commandEntries().slice(0, 5)
|
|
||||||
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.committed) return
|
|
||||||
state.cleanup?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandPaletteView
|
|
||||||
placeholder={language.t("palette.search.placeholder.home")}
|
|
||||||
loadItems={loadItems}
|
|
||||||
highlight={highlight}
|
|
||||||
select={select}
|
|
||||||
close={() => dialog.close()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPaletteView(props: {
|
|
||||||
placeholder: string
|
|
||||||
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
|
||||||
highlight: (item: CommandPaletteEntry | undefined) => void
|
|
||||||
select: (item: CommandPaletteEntry | undefined) => void
|
|
||||||
close: () => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const [active, setActive] = createSignal(0)
|
|
||||||
|
|
||||||
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
|
||||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||||
const openSessions = createMemo(
|
|
||||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
query()
|
query()
|
||||||
@@ -151,7 +59,7 @@ function CommandPaletteView(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.highlight(activeEntry())
|
palette.highlight(activeEntry())
|
||||||
})
|
})
|
||||||
|
|
||||||
let resultsRef: HTMLDivElement | undefined
|
let resultsRef: HTMLDivElement | undefined
|
||||||
@@ -178,12 +86,12 @@ function CommandPaletteView(props: {
|
|||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.select(activeEntry())
|
palette.select(activeEntry())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.close()
|
palette.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +105,7 @@ function CommandPaletteView(props: {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
appearance="large"
|
appearance="large"
|
||||||
placeholder={props.placeholder}
|
placeholder={palette.language.t("palette.search.placeholder")}
|
||||||
leadingIcon={<Icon name="magnifying-glass" />}
|
leadingIcon={<Icon name="magnifying-glass" />}
|
||||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -209,7 +117,7 @@ function CommandPaletteView(props: {
|
|||||||
when={visibleEntries().length > 0}
|
when={visibleEntries().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="command-palette-v2-state">
|
<div class="command-palette-v2-state">
|
||||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -224,14 +132,9 @@ function CommandPaletteView(props: {
|
|||||||
<PaletteRow
|
<PaletteRow
|
||||||
item={item}
|
item={item}
|
||||||
active={activeEntry()?.id === item.id}
|
active={activeEntry()?.id === item.id}
|
||||||
language={language}
|
language={palette.language}
|
||||||
sessionOpen={
|
|
||||||
item.server && item.sessionID
|
|
||||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||||
onSelect={() => props.select(item)}
|
onSelect={() => palette.select(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -250,19 +153,13 @@ function PaletteRow(props: {
|
|||||||
item: CommandPaletteEntry
|
item: CommandPaletteEntry
|
||||||
active: boolean
|
active: boolean
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
sessionOpen: boolean
|
|
||||||
onActive: () => void
|
onActive: () => void
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const session = () =>
|
|
||||||
props.item.server && props.item.directory && props.item.sessionID
|
|
||||||
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="command-palette-v2-row group"
|
class="command-palette-v2-row"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={props.active}
|
aria-selected={props.active}
|
||||||
data-active={props.active ? "" : undefined}
|
data-active={props.active ? "" : undefined}
|
||||||
@@ -300,25 +197,7 @@ function PaletteRow(props: {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={props.item.type === "session"}>
|
<Match when={props.item.type === "session"}>
|
||||||
<div class="command-palette-v2-row-main">
|
<div class="command-palette-v2-row-main">
|
||||||
<div class="relative shrink-0">
|
<Icon name="status" class="command-palette-v2-row-icon" />
|
||||||
<Show when={props.sessionOpen}>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
|
||||||
style={{ right: "calc(100% + 4px)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={session()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={props.item.project}
|
|
||||||
directory={session().directory}
|
|
||||||
sessionId={session().sessionID}
|
|
||||||
server={session().server}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="command-palette-v2-row-text">
|
<div class="command-palette-v2-row-text">
|
||||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
{props.item.title}
|
{props.item.title}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
|
||||||
import { mockProviderAuth } from "@/context/server-sync"
|
|
||||||
import { onCleanup, onMount } from "solid-js"
|
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
|
||||||
|
|
||||||
function ConnectProviderDialogStory() {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const open = () => dialog.show(() => <DialogConnectProvider />)
|
|
||||||
|
|
||||||
onMount(open)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="secondary" onClick={open}>
|
|
||||||
Open connect provider dialog
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderConnectionDialogStory(props) {
|
|
||||||
onCleanup(mockProviderAuth(props.provider, props.methods))
|
|
||||||
const dialog = useDialog()
|
|
||||||
const controller = useProviderConnectController()
|
|
||||||
controller.select(props.provider)
|
|
||||||
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
|
||||||
|
|
||||||
onMount(open)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="secondary" onClick={open}>
|
|
||||||
Open {props.provider} connection dialog
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderConnection(provider, methods) {
|
|
||||||
return () => (
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "App/Dialogs/Connect Provider",
|
|
||||||
id: "app-dialog-connect-provider",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const V2 = {
|
|
||||||
render: () => (
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<ConnectProviderDialogStory />
|
|
||||||
</QueryClientProvider>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApiKey = {
|
|
||||||
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OpenCodeZen = {
|
|
||||||
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoginMethods = {
|
|
||||||
render: renderConnection("openai", [
|
|
||||||
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
|
||||||
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
|
||||||
{ type: "api", label: "API key" },
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -9,9 +9,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import {
|
import {
|
||||||
type Accessor,
|
type Accessor,
|
||||||
@@ -19,8 +16,6 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
createUniqueId,
|
|
||||||
For,
|
|
||||||
Match,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
@@ -28,18 +23,14 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { Link } from "@/components/link"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
@@ -59,22 +50,32 @@ export const DialogConnectProvider: Component<{
|
|||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
const controller = props.controller ?? fallback
|
const controller = props.controller ?? fallback
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
|
||||||
const reset = controller.back
|
const reset = controller.back
|
||||||
const back = { current: reset }
|
const back = { current: reset }
|
||||||
let focusHost: HTMLDivElement | undefined
|
|
||||||
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
|
||||||
const select = (provider?: string) => {
|
const select = (provider?: string) => {
|
||||||
back.current = reset
|
back.current = reset
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Content() {
|
return (
|
||||||
return (
|
<Dialog
|
||||||
|
class="h-full"
|
||||||
|
transition
|
||||||
|
title={
|
||||||
|
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
<CustomProviderForm autofocus={!newLayout()} />
|
<CustomProviderForm />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
@@ -87,77 +88,15 @@ export const DialogConnectProvider: Component<{
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<ProviderPicker
|
<ProviderPicker directory={props.directory} onSelect={select} />
|
||||||
directory={props.directory}
|
|
||||||
onSelect={select}
|
|
||||||
onPrepare={newLayout() ? holdFocus : undefined}
|
|
||||||
/>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
</Dialog>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={newLayout()}
|
|
||||||
fallback={
|
|
||||||
<Dialog
|
|
||||||
class="h-full"
|
|
||||||
transition
|
|
||||||
title={
|
|
||||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Content />
|
|
||||||
</Dialog>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DialogV2
|
|
||||||
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
|
||||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
|
||||||
>
|
|
||||||
<DialogHeader closeLabel={language.t("common.close")}>
|
|
||||||
<Show
|
|
||||||
when={controller.selected()}
|
|
||||||
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" size="small" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
|
||||||
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
</DialogBody>
|
|
||||||
</DialogV2>
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: {
|
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
||||||
directory?: Accessor<string | undefined>
|
const providers = useProviders(props.directory)
|
||||||
onSelect: (provider: string) => void
|
|
||||||
onPrepare?: () => void
|
|
||||||
}) {
|
|
||||||
const settings = useSettings()
|
|
||||||
if (settings.general.newLayoutDesigns())
|
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
|
||||||
const providers = useProviders(() => props.directory?.())
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
@@ -224,157 +163,6 @@ function ProviderPicker(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPickerV2(props: {
|
|
||||||
directory?: Accessor<string | undefined>
|
|
||||||
onSelect: (provider: string) => void
|
|
||||||
onPrepare?: () => void
|
|
||||||
}) {
|
|
||||||
const providers = useProviders(() => props.directory?.())
|
|
||||||
const language = useLanguage()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
filter: "",
|
|
||||||
active: undefined as string | undefined,
|
|
||||||
connecting: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
|
||||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
|
||||||
const all = createMemo(() => {
|
|
||||||
language.locale()
|
|
||||||
const query = store.filter.trim().toLowerCase()
|
|
||||||
const values = [custom(), ...providers.all().values()]
|
|
||||||
if (!query) return values
|
|
||||||
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
|
||||||
})
|
|
||||||
const popular = createMemo(() =>
|
|
||||||
all()
|
|
||||||
.filter((provider) => featured.includes(provider.id))
|
|
||||||
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
|
||||||
)
|
|
||||||
const other = createMemo(() =>
|
|
||||||
all()
|
|
||||||
.filter((provider) => !featured.includes(provider.id))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.id === CUSTOM_ID) return -1
|
|
||||||
if (b.id === CUSTOM_ID) return 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const rows = createMemo(() => [...popular(), ...other()])
|
|
||||||
let picker: HTMLDivElement | undefined
|
|
||||||
let search: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
onMount(() => search?.focus({ preventScroll: true }))
|
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
|
||||||
props.onPrepare?.()
|
|
||||||
props.onSelect(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
const move = (event: KeyboardEvent, direction: number) => {
|
|
||||||
const items = rows()
|
|
||||||
if (items.length === 0) return
|
|
||||||
const index = items.findIndex((provider) => provider.id === store.active)
|
|
||||||
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
|
||||||
setStore("active", items[next].id)
|
|
||||||
picker
|
|
||||||
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
|
||||||
?.focus({ preventScroll: true })
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "ArrowDown") return move(event, 1)
|
|
||||||
if (event.key === "ArrowUp") return move(event, -1)
|
|
||||||
if (event.key !== "Enter" || !store.active) return
|
|
||||||
connect(store.active)
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
|
||||||
<div class="shrink-0 px-1 pt-px">
|
|
||||||
<TextInputV2
|
|
||||||
ref={search}
|
|
||||||
type="search"
|
|
||||||
class="!w-full [font-family:var(--v2-font-family-sans)]"
|
|
||||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
|
||||||
placeholder={language.t("dialog.provider.search.placeholder")}
|
|
||||||
value={store.filter}
|
|
||||||
onInput={(event) => {
|
|
||||||
setStore({ filter: event.currentTarget.value, active: undefined })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="relative min-h-0 flex-1">
|
|
||||||
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
||||||
<For
|
|
||||||
each={[
|
|
||||||
{ title: language.t("dialog.provider.group.popular"), items: popular },
|
|
||||||
{ title: language.t("dialog.provider.group.other"), items: other },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{(group) => (
|
|
||||||
<Show when={group.items().length > 0}>
|
|
||||||
<section class="flex flex-col">
|
|
||||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
{group.title}
|
|
||||||
</div>
|
|
||||||
<For each={group.items()}>
|
|
||||||
{(provider) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-provider-id={provider.id}
|
|
||||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
|
||||||
onMouseEnter={() => setStore("active", provider.id)}
|
|
||||||
disabled={store.connecting !== undefined}
|
|
||||||
aria-busy={store.connecting === provider.id}
|
|
||||||
onClick={() => connect(provider.id)}
|
|
||||||
>
|
|
||||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
|
||||||
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
|
||||||
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
|
||||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
|
||||||
{language.t(
|
|
||||||
provider.id === "opencode"
|
|
||||||
? "dialog.provider.opencode.tagline"
|
|
||||||
: "dialog.provider.opencodeGo.tagline",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
||||||
{language.t("dialog.provider.tag.recommended")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === CUSTOM_ID}>
|
|
||||||
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
||||||
{language.t("settings.providers.tag.custom")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.connecting === provider.id}>
|
|
||||||
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={rows().length === 0}>
|
|
||||||
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
|
||||||
{language.t("dialog.provider.empty")}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
|
||||||
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderConnection(props: {
|
function ProviderConnection(props: {
|
||||||
provider: string
|
provider: string
|
||||||
directory?: Accessor<string | undefined>
|
directory?: Accessor<string | undefined>
|
||||||
@@ -384,16 +172,8 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const params = useParams()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const providers = useProviders(props.directory)
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
|
||||||
const providers = useProviders(() => props.directory?.())
|
|
||||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
|
||||||
const location = () => {
|
|
||||||
const value = directory()
|
|
||||||
return value ? { directory: value } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
@@ -408,32 +188,28 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "key" as const,
|
type: "api" as const,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const [integration] = createResource(
|
const [auth] = createResource(
|
||||||
() => ({ provider: props.provider, directory: directory() }),
|
() => props.provider,
|
||||||
(input) =>
|
async () => {
|
||||||
serverSDK()
|
const cached = serverSync().data.provider_auth[props.provider]
|
||||||
.api.integration.get({
|
if (cached) return cached
|
||||||
integrationID: input.provider,
|
const res = await serverSDK().client.provider.auth()
|
||||||
location: input.directory ? { directory: input.directory } : undefined,
|
if (!alive.value) return fallback()
|
||||||
})
|
serverSync().set("provider_auth", res.data ?? {})
|
||||||
.then((result) => result.data),
|
return res.data?.[props.provider] ?? fallback()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => integration.loading)
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||||
const methods = createMemo<ConnectMethod[]>(() => {
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||||
const values = integration.latest?.methods.filter(
|
|
||||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
|
||||||
)
|
|
||||||
return values?.length ? values : fallback()
|
|
||||||
})
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
@@ -445,7 +221,7 @@ function ProviderConnection(props: {
|
|||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
@@ -499,20 +275,10 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodDetails = (value?: { type?: string; label?: string }) => {
|
|
||||||
const label = methodLabel(value)
|
|
||||||
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
|
||||||
const hint = suffix?.[1]
|
|
||||||
return {
|
|
||||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
|
||||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(value: unknown, fallback: string): string {
|
function formatError(value: unknown, fallback: string): string {
|
||||||
if (value && typeof value === "object" && "data" in value) {
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
const data = (value as { data?: { message?: unknown } }).data
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
@@ -540,22 +306,46 @@ function ProviderConnection(props: {
|
|||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
|
if (method.type === "api" && method.prompts?.length) {
|
||||||
|
if (!inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.inputs", inputs })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
|
const start = Date.now()
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.api.integration.oauth.connect({
|
.client.provider.oauth.authorize(
|
||||||
integrationID: props.provider,
|
{
|
||||||
methodID: method.id,
|
providerID: props.provider,
|
||||||
inputs: inputs ?? {},
|
method: index,
|
||||||
location: location(),
|
inputs,
|
||||||
})
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
dispatch({ type: "auth.complete", authorization: x.data })
|
const elapsed = Date.now() - start
|
||||||
|
const delay = 1000 - elapsed
|
||||||
|
|
||||||
|
if (delay > 0) {
|
||||||
|
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
timer.current = undefined
|
||||||
|
if (!alive.value) return
|
||||||
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
|
}, delay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
@@ -570,9 +360,9 @@ function ProviderConnection(props: {
|
|||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo(() => {
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
return value?.prompts ?? []
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
@@ -603,6 +393,10 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (method()?.type === "api") {
|
||||||
|
dispatch({ type: "auth.inputs", inputs: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,9 +498,7 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSync()
|
await serverSDK().client.global.dispose()
|
||||||
.refreshProviders()
|
|
||||||
.catch(() => undefined)
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -727,37 +519,6 @@ function ProviderConnection(props: {
|
|||||||
props.setBack(goBack)
|
props.setBack(goBack)
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<For each={methods()}>
|
|
||||||
{(item, index) => {
|
|
||||||
const details = () => methodDetails(item)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
||||||
onClick={() => void selectMethod(index())}
|
|
||||||
>
|
|
||||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
|
||||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
|
||||||
</span>
|
|
||||||
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
|
||||||
<Show when={details().hint}>
|
|
||||||
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -770,7 +531,7 @@ function ProviderConnection(props: {
|
|||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label ?? m?.type}
|
key={(m) => m?.label}
|
||||||
onSelect={async (selected, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
void selectMethod(index)
|
void selectMethod(index)
|
||||||
@@ -791,18 +552,11 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ApiAuthView() {
|
function ApiAuthView() {
|
||||||
let apiKey: HTMLInputElement | undefined
|
|
||||||
const errorID = createUniqueId()
|
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!newLayout()) return
|
|
||||||
apiKey?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -816,67 +570,17 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().api.integration.connect.key({
|
await serverSDK().client.auth.set({
|
||||||
integrationID: props.provider,
|
providerID: props.provider,
|
||||||
location: location(),
|
auth: {
|
||||||
key: apiKey,
|
type: "api",
|
||||||
|
key: apiKey,
|
||||||
|
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
<Show
|
|
||||||
when={provider().id === "opencode"}
|
|
||||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
|
||||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
|
||||||
<div>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
||||||
<ExternalLink
|
|
||||||
href="https://opencode.ai/zen"
|
|
||||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
|
||||||
>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
||||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
<TextInputV2
|
|
||||||
ref={apiKey}
|
|
||||||
class="!w-full"
|
|
||||||
name="apiKey"
|
|
||||||
data-input="provider-api-key"
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
value={formStore.value}
|
|
||||||
invalid={formStore.error !== undefined}
|
|
||||||
aria-describedby={formStore.error ? errorID : undefined}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Show when={formStore.error}>
|
|
||||||
{(error) => (
|
|
||||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
|
||||||
{language.t("common.continue")}
|
|
||||||
</ButtonV2>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -886,9 +590,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -901,8 +605,7 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus={!newLayout()}
|
autofocus
|
||||||
ref={apiKey}
|
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
@@ -921,18 +624,11 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OAuthCodeView() {
|
function OAuthCodeView() {
|
||||||
let codeInput: HTMLInputElement | undefined
|
|
||||||
const errorID = createUniqueId()
|
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!newLayout()) return
|
|
||||||
codeInput?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -947,13 +643,12 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.api.integration.oauth.complete({
|
.client.provider.oauth.callback({
|
||||||
integrationID: props.provider,
|
providerID: props.provider,
|
||||||
attemptID: store.authorization!.attemptID,
|
method: store.methodIndex,
|
||||||
location: location(),
|
|
||||||
code,
|
code,
|
||||||
})
|
})
|
||||||
.then(() => ({ ok: true as const }))
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
@@ -962,59 +657,16 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
<div>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
|
||||||
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
||||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
||||||
<TextInputV2
|
|
||||||
ref={codeInput}
|
|
||||||
class="!w-full"
|
|
||||||
name="code"
|
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
||||||
value={formStore.value}
|
|
||||||
invalid={formStore.error !== undefined}
|
|
||||||
aria-describedby={formStore.error ? errorID : undefined}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Show when={formStore.error}>
|
|
||||||
{(error) => (
|
|
||||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<ButtonV2 type="submit" variant="contrast">
|
|
||||||
{language.t("common.continue")}
|
|
||||||
</ButtonV2>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<ExternalLink href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus={!newLayout()}
|
autofocus
|
||||||
ref={codeInput}
|
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
@@ -1042,46 +694,32 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const poll = async () => {
|
void (async () => {
|
||||||
const authorization = store.authorization
|
|
||||||
if (!authorization || !alive.value) return
|
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.api.integration.oauth.status({
|
.client.provider.oauth.callback({
|
||||||
integrationID: props.provider,
|
providerID: props.provider,
|
||||||
attemptID: authorization.attemptID,
|
method: store.methodIndex,
|
||||||
location: location(),
|
|
||||||
})
|
})
|
||||||
.then((value) => ({ ok: true as const, status: value.data }))
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||||
|
dispatch({ type: "auth.error", error: message })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.status.status === "complete") {
|
|
||||||
await complete()
|
await complete()
|
||||||
return
|
})()
|
||||||
}
|
|
||||||
if (result.status.status === "failed") {
|
|
||||||
dispatch({ type: "auth.error", error: result.status.message })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.status.status === "expired") {
|
|
||||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer.current = setTimeout(poll, 1_000)
|
|
||||||
}
|
|
||||||
void poll()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
<ExternalLink href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1100,19 +738,10 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||||
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon
|
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||||
id={props.provider}
|
<div class="text-16-medium text-text-strong">
|
||||||
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class={
|
|
||||||
newLayout()
|
|
||||||
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
|
||||||
: "text-16-medium text-text-strong"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
{language.t("provider.connect.title.anthropicProMax")}
|
{language.t("provider.connect.title.anthropicProMax")}
|
||||||
@@ -1121,12 +750,8 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<div
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
onKeyDown={handleKey}
|
|
||||||
tabIndex={newLayout() ? undefined : 0}
|
|
||||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={loading()}>
|
<Match when={loading()}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -1158,15 +783,15 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "key"}>
|
<Match when={method()?.type === "api"}>
|
||||||
<ApiAuthView />
|
<ApiAuthView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.mode === "code"}>
|
<Match when={store.authorization?.method === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.mode === "auto"}>
|
<Match when={store.authorization?.method === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
export function CustomProviderForm() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -131,7 +131,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
@@ -178,22 +177,22 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
{language.t("provider.custom.description.prefix")}
|
{language.t("provider.custom.description.prefix")}
|
||||||
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
{language.t("provider.custom.description.link")}
|
{language.t("provider.custom.description.link")}
|
||||||
</ExternalLink>
|
</Link>
|
||||||
{language.t("provider.custom.description.suffix")}
|
{language.t("provider.custom.description.suffix")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus={props.autofocus ?? true}
|
autofocus
|
||||||
label={language.t("provider.custom.field.providerID.label")}
|
label={language.t("provider.custom.field.providerID.label")}
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
||||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
|
||||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|
||||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { For, Show } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
|
||||||
import { createEditProjectModel } from "./edit-project"
|
|
||||||
|
|
||||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const model = createEditProjectModel(props)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog fit>
|
|
||||||
<form onSubmit={model.submit} class="contents">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DividerV2 />
|
|
||||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
|
||||||
<Field>
|
|
||||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
|
||||||
<TextInputV2
|
|
||||||
autofocus
|
|
||||||
appearance="large"
|
|
||||||
class="!w-full"
|
|
||||||
value={model.store.name}
|
|
||||||
placeholder={model.folderName()}
|
|
||||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
{language.t("dialog.project.edit.icon")}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
|
||||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
|
||||||
classList={{
|
|
||||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
variant={getProjectAvatarVariant(model.store.color)}
|
|
||||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover,
|
|
||||||
"opacity-0": !model.store.iconHover,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={(element) => {
|
|
||||||
model.setIconInput(element)
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
|
||||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
{language.t("dialog.project.edit.color")}
|
|
||||||
</div>
|
|
||||||
<div class="-ml-1 flex gap-1.5">
|
|
||||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
|
||||||
{(color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
|
||||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
|
||||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
|
||||||
classList={{
|
|
||||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
|
||||||
getProjectAvatarVariant(model.store.color) === color,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
|
||||||
model.setStore(
|
|
||||||
"color",
|
|
||||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
variant={getProjectAvatarVariant(color)}
|
|
||||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
|
||||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
|
||||||
<TextareaV2
|
|
||||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
|
||||||
rows={3}
|
|
||||||
value={model.store.startup}
|
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,123 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const model = createEditProjectModel(props)
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
|
const serverSDK = () => serverCtx().sdk
|
||||||
|
const serverSync = () => serverCtx().sync
|
||||||
|
|
||||||
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
name: defaultName(),
|
||||||
|
color: props.project.icon?.color,
|
||||||
|
iconOverride: props.project.icon?.override,
|
||||||
|
startup: props.project.commands?.start ?? "",
|
||||||
|
dragOver: false,
|
||||||
|
iconHover: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
function handleFileSelect(file: File) {
|
||||||
|
if (!file.type.startsWith("image/")) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setStore("iconOverride", e.target?.result as string)
|
||||||
|
setStore("iconHover", false)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("dragOver", false)
|
||||||
|
const file = e.dataTransfer?.files[0]
|
||||||
|
if (file) handleFileSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("dragOver", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
setStore("dragOver", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) handleFileSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIcon() {
|
||||||
|
setStore("iconOverride", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
await serverSDK().client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSync().project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
saveMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.project.edit.name")}
|
label={language.t("dialog.project.edit.name")}
|
||||||
placeholder={model.folderName()}
|
placeholder={folderName()}
|
||||||
value={model.store.name}
|
value={store.name}
|
||||||
onChange={(v) => model.setStore("name", v)}
|
onChange={(v) => setStore("name", v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div class="flex gap-3 items-start">
|
<div class="flex gap-3 items-start">
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
onMouseEnter={() => setStore("iconHover", true)}
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
onMouseLeave={() => setStore("iconHover", false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
classList={{
|
classList={{
|
||||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
||||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
"border-border-base hover:border-border-strong": !store.dragOver,
|
||||||
"overflow-hidden": !!model.store.iconOverride,
|
"overflow-hidden": !!store.iconOverride,
|
||||||
|
}}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => {
|
||||||
|
if (store.iconOverride && store.iconHover) {
|
||||||
|
clearIcon()
|
||||||
|
} else {
|
||||||
|
iconInput?.click()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
when={getProjectAvatarSource(props.project.id, {
|
||||||
color: model.store.color,
|
color: store.color,
|
||||||
url: props.project.icon?.url,
|
url: props.project.icon?.url,
|
||||||
override: model.store.iconOverride,
|
override: store.iconOverride,
|
||||||
})}
|
})}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-full flex items-center justify-center">
|
<div class="size-full flex items-center justify-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={model.store.name || model.defaultName()}
|
fallback={store.name || defaultName()}
|
||||||
{...getAvatarColors(model.store.color)}
|
{...getAvatarColors(store.color)}
|
||||||
class="size-full text-[32px]"
|
class="size-full text-[32px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
"opacity-100": store.iconHover && !store.iconOverride,
|
||||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
"opacity-0": !(store.iconHover && !store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
"opacity-100": store.iconHover && !!store.iconOverride,
|
||||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<input
|
<input
|
||||||
id="icon-upload"
|
id="icon-upload"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
model.setIconInput(el)
|
iconInput = el
|
||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={model.inputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
@@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
<Show when={!store.iconOverride}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
@@ -119,21 +216,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
aria-pressed={model.store.color === color}
|
aria-pressed={store.color === color}
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||||
model.store.color === color,
|
store.color === color,
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
model.store.color !== color,
|
store.color !== color,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (model.store.color === color && !props.project.icon?.url) return
|
if (store.color === color && !props.project.icon?.url) return
|
||||||
model.setStore("color", model.store.color === color ? undefined : color)
|
setStore("color", store.color === color ? undefined : color)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={model.store.name || model.defaultName()}
|
fallback={store.name || defaultName()}
|
||||||
{...getAvatarColors(color)}
|
{...getAvatarColors(color)}
|
||||||
class="size-full rounded"
|
class="size-full rounded"
|
||||||
/>
|
/>
|
||||||
@@ -149,19 +246,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
label={language.t("dialog.project.edit.worktree.startup")}
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
value={model.store.startup}
|
value={store.startup}
|
||||||
onChange={(v) => model.setStore("startup", v)}
|
onChange={(v) => setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -69,11 +69,15 @@ export const DialogFork: Component = () => {
|
|||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.api.session.fork({ sessionID, messageID: item.id })
|
.client.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
|
if (!forked.data) {
|
||||||
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||||
navigate(`/${dir}/session/${forked.id}`)
|
navigate(`/${dir}/session/${forked.data.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
activeTreeNavigation,
|
activeTreeNavigation,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -69,13 +67,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
() =>
|
||||||
if ((await sdk.protocol) !== "v1") return
|
sdk.client.path
|
||||||
return sdk.client.path
|
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined),
|
||||||
},
|
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
@@ -89,26 +85,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
const cleaned = cleanPickerInput(value)
|
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||||
const typed = cleaned.replace(/\/+$/, "")
|
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
if (!typed || typed === current) return { query: value, items: [] }
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const base = pickerRoot(cleaned) || root() || start()
|
const files = await sdk.client.find
|
||||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||||
const files = await sdk.api.file
|
.then((result) => result.data ?? [])
|
||||||
.find({
|
|
||||||
location: { directory: base },
|
|
||||||
query: pickerFileSearchQuery(base, value, home()),
|
|
||||||
type: "file",
|
|
||||||
limit: 20,
|
|
||||||
})
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...directories,
|
||||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
@@ -127,14 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.api.file
|
return sdk.client.file
|
||||||
.list({ location: { directory: absolute } })
|
.list({ directory: absolute, path: "" })
|
||||||
.then((result) =>
|
.then((result) => result.data ?? [])
|
||||||
result.data.map((entry) => ({
|
|
||||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
|
||||||
type: entry.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
@@ -329,7 +312,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
{(suggestion, index) => (
|
{(suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
data-directory-path={suggestion.absolute}
|
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index() === activeSuggestion()}
|
aria-selected={index() === activeSuggestion()}
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -18,8 +17,6 @@ interface DialogSelectDirectoryProps {
|
|||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECENT_PROJECT_LIMIT = 5
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
absolute: string
|
absolute: string
|
||||||
search: string
|
search: string
|
||||||
@@ -59,14 +56,13 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingHome = createMemo(() => !sync.data.path.home)
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingHome() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
async () => {
|
||||||
if ((await sdk.protocol) !== "v1") return
|
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
@@ -104,6 +100,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return projects
|
return projects
|
||||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||||
|
.slice(0, 5)
|
||||||
.map(({ project }) => {
|
.map(({ project }) => {
|
||||||
const row = toRow(project.worktree, home(), "recent")
|
const row = toRow(project.worktree, home(), "recent")
|
||||||
const name = project.name || getFilename(project.worktree)
|
const name = project.name || getFilename(project.worktree)
|
||||||
@@ -117,10 +114,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||||
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||||
const recent = recentProjects()
|
|
||||||
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
|
||||||
return uniqueRows([...visible, ...directoryRows])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -168,7 +162,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@@ -180,7 +174,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || x.status === "pending" || toggle.isPending) return
|
if (!x || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
disabled={toggle.isPending && toggle.variables === i.name}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { createSignal, onMount } from "solid-js"
|
|
||||||
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
|
||||||
|
|
||||||
const names = [
|
|
||||||
"MiMo V2.5 Free",
|
|
||||||
"Nemotron 3 Ultra Free",
|
|
||||||
"Deepseek V4 Flash Free",
|
|
||||||
"North Mini Code Free",
|
|
||||||
"Hy3 Free",
|
|
||||||
"Big Pickle",
|
|
||||||
]
|
|
||||||
|
|
||||||
function SelectModelWithoutProviders() {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const models = names.map((name, index) => ({
|
|
||||||
id: name.toLowerCase().replaceAll(" ", "-"),
|
|
||||||
name,
|
|
||||||
provider: { id: "opencode", name: "OpenCode" },
|
|
||||||
cost: { input: 0, output: 0 },
|
|
||||||
limit: { context: 128_000 },
|
|
||||||
capabilities: {
|
|
||||||
reasoning: index !== 5,
|
|
||||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
const [current, setCurrent] = createSignal(models[2])
|
|
||||||
const model = {
|
|
||||||
list: () => models,
|
|
||||||
current,
|
|
||||||
set(value) {
|
|
||||||
setCurrent(models.find((item) => item.id === value?.modelID))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
|
||||||
|
|
||||||
onMount(open)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="secondary" onClick={open}>
|
|
||||||
Open select model dialog
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "App/Dialogs/Select Model",
|
|
||||||
id: "app-dialog-select-model",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WithoutProviders = {
|
|
||||||
render: () => <SelectModelWithoutProviders />,
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
|
||||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
|
||||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
|
||||||
|
|
||||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const model = props.model ?? local.model
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const theme = useTheme()
|
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const providers = useProviders(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -31,7 +28,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||||
const freeModels = createMemo(() => model.list().filter(isFree))
|
|
||||||
|
|
||||||
const openProviders = (provider?: string) => {
|
const openProviders = (provider?: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
@@ -66,110 +62,111 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogV2
|
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
||||||
fit
|
|
||||||
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
|
||||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
|
||||||
>
|
|
||||||
<DialogHeader closeLabel={language.t("common.close")}>
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||||
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
<ScrollView class="min-h-0 flex-1 w-full">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
<div ref={listEl} class="flex min-h-full flex-col">
|
||||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||||
</div>
|
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
</div>
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
<For each={freeModels()}>
|
|
||||||
{(item) => (
|
|
||||||
<TooltipV2
|
|
||||||
class="w-full"
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
openDelay={0}
|
|
||||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
|
||||||
value={
|
|
||||||
<ModelTooltip
|
|
||||||
model={{ ...item, name: displayModelName(item.name) }}
|
|
||||||
latest={item.latest}
|
|
||||||
free={isFree(item)}
|
|
||||||
v2
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => selectModel(item)}
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
|
||||||
<Show when={item.latest}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={currentKey() === modelKey(item)}>
|
|
||||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</TooltipV2>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col">
|
|
||||||
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
|
||||||
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
|
||||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.model.unpaid.addMore.title")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
<For each={model.list()}>
|
||||||
<For
|
{(item) => (
|
||||||
each={[...providers.popular()]
|
<TooltipV2
|
||||||
.filter((provider) => featuredProviders.includes(provider.id))
|
class="w-full"
|
||||||
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
placement="right-start"
|
||||||
>
|
gutter={6}
|
||||||
{(provider) => (
|
openDelay={0}
|
||||||
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-provider-id={provider.id}
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
onClick={() => selectModel(item)}
|
||||||
classList={{
|
|
||||||
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
|
||||||
theme.mode() !== "dark",
|
|
||||||
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
|
||||||
}}
|
|
||||||
onClick={() => openProviders(provider.id)}
|
|
||||||
>
|
>
|
||||||
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
<span class="min-w-0 truncate">{item.name}</span>
|
||||||
<span class="flex min-w-0 flex-col">
|
<Show when={isFree(item)}>
|
||||||
<span class="truncate">{provider.name}</span>
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
</Show>
|
||||||
<span class="truncate font-[440] text-v2-text-text-muted">
|
<Show when={item.latest}>
|
||||||
{language.t(
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
provider.id === "opencode"
|
</Show>
|
||||||
? "dialog.provider.opencode.tagline"
|
<Show when={currentKey() === modelKey(item)}>
|
||||||
: "dialog.provider.opencodeGo.tagline",
|
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
)}
|
</Show>
|
||||||
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col p-2.5 pt-0">
|
||||||
|
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||||
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||||
|
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<For
|
||||||
|
each={[...providers.popular()].sort((a, b) => {
|
||||||
|
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||||
|
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => openProviders(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode"}>
|
||||||
|
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.provider.opencode.tagline")}
|
||||||
|
</span>
|
||||||
|
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</Show>
|
||||||
|
<Show when={provider.id === "opencode-go"}>
|
||||||
|
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||||
|
</span>
|
||||||
|
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</Show>
|
||||||
|
<Show when={provider.id === "anthropic"}>
|
||||||
|
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.provider.anthropic.note")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</For>
|
||||||
</For>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
onClick={() => openProviders()}
|
||||||
onClick={() => openProviders()}
|
>
|
||||||
>
|
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||||
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
<Icon name="dot-grid" size="small" />
|
||||||
</button>
|
</span>
|
||||||
|
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.provider.viewAll")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollView>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogV2>
|
</DialogV2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
import {
|
||||||
|
Component,
|
||||||
|
ComponentProps,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
For,
|
||||||
|
JSX,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
ValidComponent,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -19,7 +29,6 @@ import { ModelTooltip } from "./model-tooltip"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
@@ -113,13 +122,14 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
trigger: ModelSelectorTrigger
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -164,7 +174,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.trigger} />
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
@@ -225,101 +237,66 @@ export function ModelSelectorPopover(props: {
|
|||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
trigger: ModelSelectorTrigger
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = createModelSelectorController({
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
model: props.model,
|
let searchRef: HTMLInputElement | undefined
|
||||||
provider: () => props.provider,
|
let contentRef: HTMLDivElement | undefined
|
||||||
onSelect: () => props.onClose?.(),
|
let restoreTrigger = true
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModelSelectorPopoverV2View
|
|
||||||
trigger={props.trigger}
|
|
||||||
models={controller.models}
|
|
||||||
groups={controller.groups}
|
|
||||||
current={controller.current}
|
|
||||||
select={controller.select}
|
|
||||||
onManage={() => {
|
|
||||||
void import("./dialog-manage-models").then((module) => {
|
|
||||||
void dialog.show(() => <module.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onClose={() => props.onClose?.()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createModelSelectorController(input: {
|
|
||||||
provider: () => string | undefined
|
|
||||||
model?: ModelState
|
|
||||||
onSelect: () => void
|
|
||||||
}) {
|
|
||||||
const model = input.model ?? useLocal().model
|
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
const models = createMemo(() => {
|
||||||
|
const search = store.search.trim()
|
||||||
|
const filtered = search
|
||||||
|
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||||
|
: allModels()
|
||||||
|
|
||||||
return {
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
models: (search: string) => {
|
})
|
||||||
const query = search.trim()
|
const groups = createMemo(() => {
|
||||||
const filtered = query
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
for (const item of models()) {
|
||||||
: allModels()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
}
|
||||||
},
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
groups: (models: ModelItem[]) => {
|
})
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
|
||||||
for (const item of models) {
|
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
|
||||||
}
|
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
|
||||||
},
|
|
||||||
current: () => {
|
|
||||||
const value = model.current()
|
|
||||||
return value ? modelKey(value) : undefined
|
|
||||||
},
|
|
||||||
select: (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
input.onSelect()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelSelectorPopoverV2View(props: {
|
|
||||||
trigger: ModelSelectorTrigger
|
|
||||||
models: (search: string) => ModelItem[]
|
|
||||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
|
||||||
current: () => string | undefined
|
|
||||||
select: (item: ModelItem) => void
|
|
||||||
onManage: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
|
||||||
let searchRef: HTMLInputElement | undefined
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
const dismiss = createMenuDismissController(() => contentRef)
|
|
||||||
|
|
||||||
const models = createMemo(() => props.models(store.search))
|
|
||||||
const groups = createMemo(() => props.groups(models()))
|
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
|
const current = () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||||
|
}
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = props.current()
|
const selected = current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
dismiss.allowTriggerRestore()
|
restoreTrigger = true
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -331,15 +308,23 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
|
const select = (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
props.onClose?.()
|
||||||
|
}
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(() => props.select(item))
|
afterClose(() => select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(props.onManage)
|
afterClose(() => {
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
@@ -358,7 +343,10 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const first = props.models(value)[0]
|
const search = value.trim()
|
||||||
|
const first = [...allModels()]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,14 +362,18 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.trigger} />
|
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
onFocusOutside={dismiss.preventTriggerRestore}
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -401,9 +393,9 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
dismiss.preventTriggerRestore()
|
restoreTrigger = false
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
dismiss.afterClose(props.onClose)
|
afterClose(() => props.onClose?.())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
@@ -453,7 +445,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={props.current()}>
|
<MenuV2.RadioGroup value={current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -473,7 +465,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!settings.general.newLayoutDesigns() &&
|
|
||||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
|
||||||
) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
@@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const raw = items()
|
const list = items()
|
||||||
const list = settings.general.newLayoutDesigns()
|
|
||||||
? raw
|
|
||||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const runAction = () => {
|
const runAction = () => {
|
||||||
if (props.link) platform.openExternal(props.link)
|
if (props.link) platform.openLink(props.link)
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
api: {
|
client: {
|
||||||
file: {
|
find: {
|
||||||
find: (input: { location?: { directory?: string } }) => {
|
files: (input: { directory: string }) => {
|
||||||
directories.push(input.location?.directory ?? "")
|
directories.push(input.directory)
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -152,29 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () =
|
|||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("searches from an absolute root without a default base", async () => {
|
|
||||||
const directories: string[] = []
|
|
||||||
const sdk = {
|
|
||||||
api: {
|
|
||||||
file: {
|
|
||||||
list: (input: { location?: { directory?: string } }) => {
|
|
||||||
directories.push(input.location?.directory ?? "")
|
|
||||||
return Promise.resolve({
|
|
||||||
data: [
|
|
||||||
{ path: "Users/", type: "directory" },
|
|
||||||
{ path: "tmp/", type: "directory" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
|
||||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
|
||||||
|
|
||||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
|
||||||
expect(directories).toEqual(["/"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies the next directory level to preload", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|||||||
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
const root = pickerRoot(raw)
|
|
||||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
|
||||||
const base = args.base()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
const raw = normalizePickerDrive(value)
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||||
|
const root = pickerRoot(raw)
|
||||||
|
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||||
return { directory: trimPickerPath(base), path: raw }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,17 +342,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.api.file
|
const request = args.sdk.client.file
|
||||||
.list({ location: { directory: key } })
|
.list({ directory: key, path: "" })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.filter((node) => node.type === "directory")
|
||||||
.map((node) => {
|
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
|
||||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
cache.set(key, request)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
@@ -374,9 +371,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.api.file
|
const results = await args.sdk.client.find
|
||||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data.map((entry) => entry.path))
|
.then((result) => result.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
|
||||||
import { createMemo } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { type LocalProject } from "@/context/layout"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
|
|
||||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
|
||||||
const defaultName = createMemo(() => props.project.name || folderName())
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
name: defaultName(),
|
|
||||||
color: props.project.icon?.color,
|
|
||||||
iconOverride: props.project.icon?.override,
|
|
||||||
startup: props.project.commands?.start ?? "",
|
|
||||||
dragOver: false,
|
|
||||||
iconHover: false,
|
|
||||||
})
|
|
||||||
let iconInput: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
function selectFile(file: File) {
|
|
||||||
if (!file.type.startsWith("image/")) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const result = event.target?.result
|
|
||||||
if (typeof result !== "string") return
|
|
||||||
setStore("iconOverride", result)
|
|
||||||
setStore("iconHover", false)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function drop(event: DragEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
setStore("dragOver", false)
|
|
||||||
const file = event.dataTransfer?.files[0]
|
|
||||||
if (file) selectFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragOver(event: DragEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
setStore("dragOver", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragLeave() {
|
|
||||||
setStore("dragOver", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputChange(event: Event) {
|
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
|
||||||
if (file) selectFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconClick() {
|
|
||||||
if (store.iconOverride && store.iconHover) {
|
|
||||||
setStore("iconOverride", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
iconInput?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
|
||||||
const project = await serverCtx()
|
|
||||||
.sdk.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
.then((result) => result.data)
|
|
||||||
if (!project) return
|
|
||||||
// const project = await serverCtx().sdk.api.project.update({
|
|
||||||
// projectID: props.project.id,
|
|
||||||
// name,
|
|
||||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
// commands: { start },
|
|
||||||
// })
|
|
||||||
serverCtx().sync.set("project", (items) =>
|
|
||||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
|
||||||
)
|
|
||||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverCtx().sync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
if (save.isPending) return
|
|
||||||
save.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
folderName,
|
|
||||||
defaultName,
|
|
||||||
save,
|
|
||||||
submit,
|
|
||||||
drop,
|
|
||||||
dragOver,
|
|
||||||
dragLeave,
|
|
||||||
inputChange,
|
|
||||||
iconClick,
|
|
||||||
close() {
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
setIconInput(input: HTMLInputElement) {
|
|
||||||
iconInput = input
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
|
||||||
|
|
||||||
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExternalLink(props: ExternalLinkProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={local.href}
|
|
||||||
class={`text-text-strong underline ${local.class ?? ""}`}
|
|
||||||
target={local.target ?? "_blank"}
|
|
||||||
rel={local.rel ?? "noopener noreferrer"}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
|
||||||
|
|
||||||
describe("buildFileTreeV2Model", () => {
|
describe("file tree v2 model", () => {
|
||||||
test("builds a sorted tree and flattens expanded directories", () => {
|
test("builds sorted depth-first rows", () => {
|
||||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||||
|
|
||||||
expect(model.total).toBe(8)
|
expect(model.total).toBe(8)
|
||||||
@@ -19,7 +18,7 @@ describe("buildFileTreeV2Model", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips children of collapsed directories", () => {
|
test("omits descendants of collapsed directories", () => {
|
||||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||||
|
|
||||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||||
@@ -29,46 +28,19 @@ describe("buildFileTreeV2Model", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("normalizes duplicate and messy paths", () => {
|
test("normalizes separators and duplicate paths", () => {
|
||||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||||
const rows = flattenFileTreeV2(model, () => true)
|
const rows = flattenFileTreeV2(model, () => true)
|
||||||
|
|
||||||
|
expect(model.total).toBe(4)
|
||||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles deeply nested paths", () => {
|
test("supports paths deeper than the legacy recursion limit", () => {
|
||||||
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
||||||
const model = buildFileTreeV2Model([file])
|
const model = buildFileTreeV2Model([file])
|
||||||
|
|
||||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("flattenLiveFileTreeV2", () => {
|
|
||||||
test("flattens live children using original paths for nested lookups", () => {
|
|
||||||
const nodes: Record<string, FileNode[]> = {
|
|
||||||
"": [
|
|
||||||
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
|
||||||
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
|
||||||
],
|
|
||||||
src: [
|
|
||||||
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
|
||||||
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
|
||||||
],
|
|
||||||
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
flattenLiveFileTreeV2(
|
|
||||||
(path) => nodes[path] ?? [],
|
|
||||||
(path) => path === "src",
|
|
||||||
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
|
||||||
).toEqual([
|
|
||||||
["src", "src", 0],
|
|
||||||
["src/a.ts", "src/a.ts", 1],
|
|
||||||
["src/lib", "src/lib", 1],
|
|
||||||
["README.md", "README.md", 0],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -75,33 +75,3 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
|
|||||||
|
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flattenLiveFileTreeV2(
|
|
||||||
children: (path: string) => readonly FileNode[],
|
|
||||||
expanded: (path: string) => boolean,
|
|
||||||
) {
|
|
||||||
const rows: FileTreeV2Row[] = []
|
|
||||||
const stack = children("")
|
|
||||||
.toReversed()
|
|
||||||
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const row = stack.pop()!
|
|
||||||
rows.push(row)
|
|
||||||
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
|
||||||
const nested = children(row.node.originalPath)
|
|
||||||
for (let index = nested.length - 1; index >= 0; index--) {
|
|
||||||
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLiveNode(node: FileNode): FileTreeV2Node {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
path: normalizeFileTreeV2Path(node.path),
|
|
||||||
originalPath: node.path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||||
import {
|
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||||
buildFileTreeV2Model,
|
|
||||||
flattenFileTreeV2,
|
|
||||||
flattenLiveFileTreeV2,
|
|
||||||
normalizeFileTreeV2Path,
|
|
||||||
type FileTreeV2Node,
|
|
||||||
} from "@/components/file-tree-v2-model"
|
|
||||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||||
|
|
||||||
export type { Kind } from "@/components/file-tree"
|
export type { Kind } from "@/components/file-tree"
|
||||||
@@ -42,7 +36,7 @@ function guideLineLeft(level: number) {
|
|||||||
export const kindLabel = (kind: Kind) => {
|
export const kindLabel = (kind: Kind) => {
|
||||||
if (kind === "add") return "A"
|
if (kind === "add") return "A"
|
||||||
if (kind === "del") return "D"
|
if (kind === "del") return "D"
|
||||||
return "M"
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kindChange = (kind: Kind) => {
|
export const kindChange = (kind: Kind) => {
|
||||||
@@ -74,7 +68,7 @@ const FileTreeNodeV2 = (
|
|||||||
"class",
|
"class",
|
||||||
"classList",
|
"classList",
|
||||||
])
|
])
|
||||||
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
|
const kind = () => local.kinds?.get(local.node.path)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<Dynamic
|
||||||
@@ -116,7 +110,12 @@ const FileTreeNodeV2 = (
|
|||||||
function GuideLines(props: { level: number }) {
|
function GuideLines(props: { level: number }) {
|
||||||
return (
|
return (
|
||||||
<For each={Array.from({ length: props.level })}>
|
<For each={Array.from({ length: props.level })}>
|
||||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
{(_, index) => (
|
||||||
|
<div
|
||||||
|
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
||||||
|
style={`left: ${guideLineLeft(index())}px`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -127,18 +126,12 @@ export default function FileTreeV2(props: {
|
|||||||
kinds?: ReadonlyMap<string, Kind>
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
draggable?: boolean
|
draggable?: boolean
|
||||||
onFileClick?: (file: FileNode) => void
|
onFileClick?: (file: FileNode) => void
|
||||||
onFileDoubleClick?: (file: FileNode) => void
|
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const live = () => props.allowed === undefined
|
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||||
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
|
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
||||||
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
|
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
||||||
const rows = createMemo(() => {
|
|
||||||
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
|
|
||||||
return flattenFileTreeV2(model()!, expanded)
|
|
||||||
})
|
|
||||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||||
const [focused, setFocused] = createSignal<string>()
|
const [focused, setFocused] = createSignal<string>()
|
||||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||||
@@ -162,49 +155,16 @@ export default function FileTreeV2(props: {
|
|||||||
return [...indexes, index].sort((a, b) => a - b)
|
return [...indexes, index].sort((a, b) => a - b)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!live()) return
|
|
||||||
void file.tree.list("")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Only scroll when the active path changes (or first appears in the tree).
|
|
||||||
// Do not re-scroll when expand/collapse reshuffles `rows()`.
|
|
||||||
let scrolledActive: string | undefined
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const path = active()
|
const path = active()
|
||||||
if (!path) {
|
if (!path) return
|
||||||
scrolledActive = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const index = rows().findIndex((row) => row.node.path === path)
|
const index = rows().findIndex((row) => row.node.path === path)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
if (scrolledActive === path) return
|
|
||||||
scrolledActive = path
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const next = rows().findIndex((row) => row.node.path === path)
|
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||||
if (next < 0) return
|
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||||
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
|
|
||||||
virtualizer.scrollToIndex(next, { align: "auto" })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
|
|
||||||
action?.({
|
|
||||||
...node,
|
|
||||||
path: node.originalPath,
|
|
||||||
absolute: node.originalPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleDirectory = (path: string, originalPath: string) => {
|
|
||||||
if (expanded(path)) {
|
|
||||||
file.tree.collapse(originalPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.tree.expand(originalPath, live() ? undefined : { list: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||||
const virtualItemByKey = createMemo(
|
const virtualItemByKey = createMemo(
|
||||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||||
@@ -215,7 +175,7 @@ export default function FileTreeV2(props: {
|
|||||||
<div
|
<div
|
||||||
ref={setRoot}
|
ref={setRoot}
|
||||||
data-component="file-tree-v2"
|
data-component="file-tree-v2"
|
||||||
data-total-rows={live() ? rows().length : model()!.total}
|
data-total-rows={model().total}
|
||||||
class="group/file-tree-v2"
|
class="group/file-tree-v2"
|
||||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||||
>
|
>
|
||||||
@@ -249,8 +209,13 @@ export default function FileTreeV2(props: {
|
|||||||
class="relative"
|
class="relative"
|
||||||
onFocus={() => setFocused(row().node.path)}
|
onFocus={() => setFocused(row().node.path)}
|
||||||
onBlur={() => setFocused(undefined)}
|
onBlur={() => setFocused(undefined)}
|
||||||
onClick={() => selectFile(row().node, props.onFileClick)}
|
onClick={() =>
|
||||||
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
props.onFileClick?.({
|
||||||
|
...row().node,
|
||||||
|
path: row().node.originalPath,
|
||||||
|
absolute: row().node.originalPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<GuideLines level={row().level} />
|
<GuideLines level={row().level} />
|
||||||
<Show when={row().level > 0}>
|
<Show when={row().level > 0}>
|
||||||
@@ -274,13 +239,17 @@ export default function FileTreeV2(props: {
|
|||||||
class="relative"
|
class="relative"
|
||||||
onFocus={() => setFocused(row().node.path)}
|
onFocus={() => setFocused(row().node.path)}
|
||||||
onBlur={() => setFocused(undefined)}
|
onBlur={() => setFocused(undefined)}
|
||||||
aria-expanded={expanded(row().node.path)}
|
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
|
||||||
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
|
onClick={() =>
|
||||||
|
file.tree.state(row().node.path)?.expanded === false
|
||||||
|
? file.tree.expand(row().node.path, { list: false })
|
||||||
|
: file.tree.collapse(row().node.path)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<GuideLines level={row().level} />
|
<GuideLines level={row().level} />
|
||||||
<div
|
<div
|
||||||
data-slot="file-tree-v2-chevron"
|
data-slot="file-tree-v2-chevron"
|
||||||
data-expanded={expanded(row().node.path) ? "" : undefined}
|
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
||||||
class="size-4 flex items-center justify-center"
|
class="size-4 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" />
|
<Icon name="chevron-down" />
|
||||||
|
|||||||
@@ -1,35 +1,77 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
import homeImage from "@/assets/help/home.png"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
|
||||||
|
const helpIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="icon-svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||||
|
fill="var(--v2-icon-icon-base)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
// TODO: wire to changelog / seen-state when available
|
||||||
const showPopover = () => true
|
const showPopover = () => true
|
||||||
|
|
||||||
|
export function HelpButton() {
|
||||||
|
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||||
|
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://opencode.ai"
|
||||||
|
aria-label="Open the OpenCode website"
|
||||||
|
data-component="icon-button-v2"
|
||||||
|
data-size="large"
|
||||||
|
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(event.currentTarget.href)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{helpIcon}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||||
const platform = usePlatform()
|
|
||||||
|
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||||
|
// setState({ dismissed: false }) // for testing
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
<Show when={!state.dismissed}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
aria-label="Introducing Tabs. A faster, more intuitive way to work."
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss Tabs information"
|
aria-label="Dismiss Tabs information"
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
onClick={settings.general.dismissTabsToast}
|
onClick={() => setState("dismissed", true)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -46,7 +88,7 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.general.dismissTabsToast()
|
setState("dismissed", true)
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -65,82 +107,36 @@ export function TabsInfoPopup() {
|
|||||||
Introducing Tabs
|
Introducing Tabs
|
||||||
</p>
|
</p>
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
Organize your work and active sessions with tabs
|
A faster, more intuitive way to work.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<DrawerContent
|
<DrawerContent>
|
||||||
style={
|
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||||
windows()
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
? {
|
June 16
|
||||||
inset: "0 0 0 auto",
|
</p>
|
||||||
"max-height": "100vh",
|
|
||||||
"max-width": "100vw",
|
|
||||||
"border-radius": "0",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={windows()}>
|
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="neutral"
|
variant="ghost-muted"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
class="absolute top-[10px] left-[-36px]"
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
|
||||||
classList={{
|
|
||||||
"h-[40px] px-4": windows(),
|
|
||||||
"h-[52px] p-4": !windows(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
|
||||||
July 14
|
|
||||||
</p>
|
|
||||||
<Show when={!windows()}>
|
|
||||||
<DrawerClose
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
aria-label="Close"
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
Introducing Tabs
|
Introducing Tabs Navigation.
|
||||||
|
</p>
|
||||||
|
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
||||||
|
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
||||||
|
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
||||||
|
sessions.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
<p>OpenCode Desktop is now built around tabs.</p>
|
|
||||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>
|
|
||||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
|
||||||
you're starting something new, and close it when you're done.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
|
||||||
memorable if you plan to keep them around.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
|
||||||
</p>
|
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>When you reopen the app, your tabs are still open.</p>
|
|
||||||
<p>
|
|
||||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
|
||||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
|
||||||
will become permanent in a few weeks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link(props: LinkProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user