mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a855229052 | |||
| a638728201 | |||
| 28cb8efa4b | |||
| b0bd728275 |
+1
-5
@@ -11,7 +11,6 @@ adamdotdevin
|
|||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
-atharvau AI review spamming literally every PR
|
-atharvau AI review spamming literally every PR
|
||||||
-borealbytes
|
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
@@ -22,11 +21,8 @@ jayair
|
|||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
-opencode2026
|
||||||
-opencodeengineer bot that spams issues
|
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-robinmordasiewicz
|
|
||||||
simonklee
|
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-OpenCodeEngineer bot that spams issues
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-locales:
|
sync-locales:
|
||||||
if: false
|
if: github.actor != 'opencode-agent[bot]'
|
||||||
#if: github.actor != 'opencode-agent[bot]'
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -35,7 +34,7 @@ jobs:
|
|||||||
- name: Compute changed English docs
|
- name: Compute changed English docs
|
||||||
id: changes
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
|
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
||||||
if [ -z "$FILES" ]; then
|
if [ -z "$FILES" ]; then
|
||||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "No English docs changed in push range"
|
echo "No English docs changed in push range"
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ on:
|
|||||||
- "patches/**"
|
- "patches/**"
|
||||||
- ".github/workflows/nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||||
# do not produce byte-identical node_modules as native installs.
|
# do not produce byte-identical node_modules as native installs.
|
||||||
|
|||||||
+46
-173
@@ -67,7 +67,7 @@ jobs:
|
|||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
repo: ${{ steps.version.outputs.repo }}
|
repo: ${{ steps.version.outputs.repo }}
|
||||||
|
|
||||||
build-cli:
|
build-cli-linux-win:
|
||||||
needs: version
|
needs: version
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
@@ -94,41 +94,24 @@ jobs:
|
|||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
OPENCODE_BUILD_OS: linux,win32
|
||||||
|
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli-linux-win
|
||||||
path: |
|
path: packages/opencode/dist
|
||||||
packages/opencode/dist/opencode-darwin*
|
|
||||||
packages/opencode/dist/opencode-linux*
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
build-cli-darwin:
|
||||||
with:
|
needs: version
|
||||||
name: opencode-cli-windows
|
runs-on: macos-latest
|
||||||
path: packages/opencode/dist/opencode-windows*
|
if: github.repository == 'anomalyco/opencode'
|
||||||
outputs:
|
|
||||||
version: ${{ needs.version.outputs.version }}
|
|
||||||
|
|
||||||
sign-cli-windows:
|
|
||||||
needs:
|
|
||||||
- build-cli
|
|
||||||
- version
|
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
|
||||||
env:
|
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
||||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
fetch-tags: true
|
||||||
path: packages/opencode/dist
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Setup git committer
|
- name: Setup git committer
|
||||||
id: committer
|
id: committer
|
||||||
@@ -137,91 +120,50 @@ jobs:
|
|||||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Azure login
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
uses: azure/login@v2
|
|
||||||
with:
|
with:
|
||||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
keychain: build
|
||||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
- uses: azure/artifact-signing-action@v1
|
- name: Resolve signing identity
|
||||||
with:
|
|
||||||
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
|
||||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
||||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
|
||||||
files: |
|
|
||||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
|
||||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
|
||||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
|
||||||
exclude-environment-credential: true
|
|
||||||
exclude-workload-identity-credential: true
|
|
||||||
exclude-managed-identity-credential: true
|
|
||||||
exclude-shared-token-cache-credential: true
|
|
||||||
exclude-visual-studio-credential: true
|
|
||||||
exclude-visual-studio-code-credential: true
|
|
||||||
exclude-azure-cli-credential: false
|
|
||||||
exclude-azure-powershell-credential: true
|
|
||||||
exclude-azure-developer-cli-credential: true
|
|
||||||
exclude-interactive-browser-credential: true
|
|
||||||
|
|
||||||
- name: Verify Windows CLI signatures
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
run: |
|
||||||
$files = @(
|
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
if [ -z "$CERT_ID" ]; then
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
echo "Developer ID Application identity not found"
|
||||||
)
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||||
|
|
||||||
foreach ($file in $files) {
|
- name: Build
|
||||||
$sig = Get-AuthenticodeSignature $file
|
id: build
|
||||||
if ($sig.Status -ne "Valid") {
|
|
||||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Repack Windows CLI archives
|
|
||||||
working-directory: packages/opencode/dist
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
./packages/opencode/script/build.ts
|
||||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
|
||||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
|
||||||
|
|
||||||
- name: Upload signed Windows CLI release assets
|
|
||||||
if: needs.version.outputs.release != ''
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||||
|
OPENCODE_BUILD_OS: darwin
|
||||||
|
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||||
|
|
||||||
|
- name: Verify darwin signatures
|
||||||
run: |
|
run: |
|
||||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
for file in packages/opencode/dist/opencode-darwin-*/bin/opencode; do
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
codesign -vvv --verify "$file"
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
done
|
||||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
|
||||||
--clobber `
|
|
||||||
--repo "${{ needs.version.outputs.repo }}"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-darwin
|
||||||
path: |
|
path: packages/opencode/dist
|
||||||
packages/opencode/dist/opencode-windows-arm64
|
|
||||||
packages/opencode/dist/opencode-windows-x64
|
|
||||||
packages/opencode/dist/opencode-windows-x64-baseline
|
|
||||||
|
|
||||||
build-tauri:
|
build-tauri:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
if: github.ref_name != 'beta'
|
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
||||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -267,14 +209,6 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Azure login
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
uses: azure/login@v2
|
|
||||||
with:
|
|
||||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
@@ -313,7 +247,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
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 }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
@@ -370,35 +303,10 @@ jobs:
|
|||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
|
||||||
- name: Verify signed Windows desktop artifacts
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$files = @(
|
|
||||||
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
|
|
||||||
)
|
|
||||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
|
|
||||||
|
|
||||||
foreach ($file in $files) {
|
|
||||||
$sig = Get-AuthenticodeSignature $file
|
|
||||||
if ($sig.Status -ne "Valid") {
|
|
||||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
||||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
|
||||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -423,6 +331,7 @@ jobs:
|
|||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
platform_flag: --linux
|
platform_flag: --linux
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
# if: github.ref_name == 'beta'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
@@ -439,14 +348,6 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Azure login
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
uses: azure/login@v2
|
|
||||||
with:
|
|
||||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
@@ -481,7 +382,6 @@ 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 }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
@@ -514,22 +414,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
- name: Verify signed Windows Electron artifacts
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$files = @()
|
|
||||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
|
|
||||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
|
|
||||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
|
|
||||||
|
|
||||||
foreach ($file in $files | Select-Object -Unique) {
|
|
||||||
$sig = Get-AuthenticodeSignature $file
|
|
||||||
if ($sig.Status -ne "Valid") {
|
|
||||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-electron-${{ matrix.settings.target }}
|
name: opencode-electron-${{ matrix.settings.target }}
|
||||||
@@ -544,11 +428,10 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli-darwin
|
||||||
- sign-cli-windows
|
- build-cli-linux-win
|
||||||
- build-tauri
|
- build-tauri
|
||||||
- build-electron
|
- build-electron
|
||||||
if: always() && !failure() && !cancelled()
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -582,22 +465,12 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
pattern: opencode-cli-*
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
if: needs.version.outputs.release
|
||||||
name: opencode-cli-windows
|
|
||||||
path: packages/opencode/dist
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
if: github.ref_name != 'beta'
|
|
||||||
with:
|
|
||||||
name: opencode-cli-signed-windows
|
|
||||||
path: packages/opencode/dist
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
|
||||||
with:
|
with:
|
||||||
pattern: latest-yml-*
|
pattern: latest-yml-*
|
||||||
path: /tmp/latest-yml
|
path: /tmp/latest-yml
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: sign-cli
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- brendan/desktop-signpath
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sign-cli:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
|
||||||
|
- name: Upload unsigned Windows CLI
|
||||||
|
id: upload_unsigned_windows_cli
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-opencode-windows-cli
|
||||||
|
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Submit SignPath signing request
|
||||||
|
id: submit_signpath_signing_request
|
||||||
|
uses: signpath/github-action-submit-signing-request@v1
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||||
|
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||||
|
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed-opencode-cli
|
||||||
|
|
||||||
|
- name: Upload signed Windows CLI
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-opencode-windows-cli
|
||||||
|
path: signed-opencode-cli/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
@@ -15,10 +15,6 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
checks: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
@@ -41,11 +37,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
@@ -54,39 +45,8 @@ jobs:
|
|||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
- name: Cache Turbo
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: node_modules/.cache/turbo
|
|
||||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
|
||||||
turbo-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test:ci
|
run: bun turbo test
|
||||||
env:
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
|
||||||
|
|
||||||
- name: Publish unit reports
|
|
||||||
if: always()
|
|
||||||
uses: mikepenz/action-junit-report@v6
|
|
||||||
with:
|
|
||||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
|
||||||
check_name: "unit results (${{ matrix.settings.name }})"
|
|
||||||
detailed_summary: true
|
|
||||||
include_time_in_summary: true
|
|
||||||
fail_on_failure: false
|
|
||||||
|
|
||||||
- name: Upload unit artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
|
||||||
include-hidden-files: true
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
path: packages/*/.artifacts/unit/junit.xml
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
@@ -110,11 +70,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
@@ -145,17 +100,15 @@ jobs:
|
|||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
if: always()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
path: |
|
path: |
|
||||||
packages/app/e2e/junit-*.xml
|
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ target
|
|||||||
|
|
||||||
# Local dev files
|
# Local dev files
|
||||||
opencode-dev
|
opencode-dev
|
||||||
UPCOMING_CHANGELOG.md
|
|
||||||
logs/
|
logs/
|
||||||
*.bun-build
|
*.bun-build
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: ALWAYS use this when writing docs
|
||||||
|
color: "#38A3EE"
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert technical documentation writer
|
||||||
|
|
||||||
|
You are not verbose
|
||||||
|
|
||||||
|
Use a relaxed and friendly tone
|
||||||
|
|
||||||
|
The title of the page should be a word or a 2-3 word phrase
|
||||||
|
|
||||||
|
The description should be one short line, should not start with "The", should
|
||||||
|
avoid repeating the title of the page, should be 5-10 words long
|
||||||
|
|
||||||
|
Chunks of text should not be more than 2 sentences long
|
||||||
|
|
||||||
|
Each section is separated by a divider of 3 dashes
|
||||||
|
|
||||||
|
The section titles are short with only the first letter of the word capitalized
|
||||||
|
|
||||||
|
The section titles are in the imperative mood
|
||||||
|
|
||||||
|
The section titles should not repeat the term used in the page title, for
|
||||||
|
example, if the page title is "Models", avoid using a section title like "Add
|
||||||
|
new models". This might be unavoidable in some cases, but try to avoid it.
|
||||||
|
|
||||||
|
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
|
||||||
|
|
||||||
|
For JS or TS code snippets remove trailing semicolons and any trailing commas
|
||||||
|
that might not be needed.
|
||||||
|
|
||||||
|
If you are making a commit prefix the commit message with `docs:`
|
||||||
@@ -1,46 +1,21 @@
|
|||||||
---
|
---
|
||||||
model: opencode/gpt-5.4
|
model: opencode/kimi-k2.5
|
||||||
---
|
---
|
||||||
|
|
||||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
create UPCOMING_CHANGELOG.md
|
||||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
|
||||||
Do not preserve, merge, or reuse text from the existing file.
|
|
||||||
|
|
||||||
The input already contains the exact commit range since the last non-draft release.
|
it should have sections
|
||||||
The commits are already filtered to the release-relevant packages and grouped into
|
|
||||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
|
||||||
The input may also include a `## Community Contributors Input` section.
|
|
||||||
|
|
||||||
Before writing any entry you keep, inspect the real diff with
|
```
|
||||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
# TUI
|
||||||
understand the actual code changes and not just the commit message (they may be misleading).
|
|
||||||
Do not use `git log` or author metadata when deciding attribution.
|
|
||||||
|
|
||||||
Rules:
|
# Desktop
|
||||||
|
|
||||||
- Write the final file with sections in this order:
|
# Core
|
||||||
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
|
||||||
- Only include sections that have at least one notable entry
|
|
||||||
- Keep one bullet per commit you keep
|
|
||||||
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
|
||||||
- Start each bullet with a capital letter
|
|
||||||
- Prefer what changed for users over what code changed internally
|
|
||||||
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
|
|
||||||
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
|
|
||||||
- If an input bullet has no `(@username)` suffix, do not add one
|
|
||||||
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
|
|
||||||
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
|
|
||||||
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
|
|
||||||
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
|
|
||||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
|
||||||
- Do not derive the thank-you section from the main summary bullets
|
|
||||||
- Do not include the heading `## Community Contributors Input` in the final file
|
|
||||||
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
|
||||||
|
|
||||||
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
# Misc
|
||||||
|
```
|
||||||
|
|
||||||
<changelog_input>
|
go through each PR merged since the last tag
|
||||||
|
|
||||||
!`bun script/raw-changelog.ts $ARGUMENTS`
|
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||||
|
|
||||||
</changelog_input>
|
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/theme.json",
|
|
||||||
"defs": {
|
|
||||||
"nord0": "#2E3440",
|
|
||||||
"nord1": "#3B4252",
|
|
||||||
"nord2": "#434C5E",
|
|
||||||
"nord3": "#4C566A",
|
|
||||||
"nord4": "#D8DEE9",
|
|
||||||
"nord5": "#E5E9F0",
|
|
||||||
"nord6": "#ECEFF4",
|
|
||||||
"nord7": "#8FBCBB",
|
|
||||||
"nord8": "#88C0D0",
|
|
||||||
"nord9": "#81A1C1",
|
|
||||||
"nord10": "#5E81AC",
|
|
||||||
"nord11": "#BF616A",
|
|
||||||
"nord12": "#D08770",
|
|
||||||
"nord13": "#EBCB8B",
|
|
||||||
"nord14": "#A3BE8C",
|
|
||||||
"nord15": "#B48EAD"
|
|
||||||
},
|
|
||||||
"theme": {
|
|
||||||
"primary": {
|
|
||||||
"dark": "nord10",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"secondary": {
|
|
||||||
"dark": "nord9",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"accent": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"dark": "nord11",
|
|
||||||
"light": "nord11"
|
|
||||||
},
|
|
||||||
"warning": {
|
|
||||||
"dark": "nord12",
|
|
||||||
"light": "nord12"
|
|
||||||
},
|
|
||||||
"success": {
|
|
||||||
"dark": "nord14",
|
|
||||||
"light": "nord14"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"dark": "nord8",
|
|
||||||
"light": "nord10"
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"dark": "nord6",
|
|
||||||
"light": "nord0"
|
|
||||||
},
|
|
||||||
"textMuted": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord1"
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"dark": "nord0",
|
|
||||||
"light": "nord6"
|
|
||||||
},
|
|
||||||
"backgroundPanel": {
|
|
||||||
"dark": "nord1",
|
|
||||||
"light": "nord5"
|
|
||||||
},
|
|
||||||
"backgroundElement": {
|
|
||||||
"dark": "nord2",
|
|
||||||
"light": "nord4"
|
|
||||||
},
|
|
||||||
"border": {
|
|
||||||
"dark": "nord2",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"borderActive": {
|
|
||||||
"dark": "nord3",
|
|
||||||
"light": "nord2"
|
|
||||||
},
|
|
||||||
"borderSubtle": {
|
|
||||||
"dark": "nord2",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"diffAdded": {
|
|
||||||
"dark": "nord14",
|
|
||||||
"light": "nord14"
|
|
||||||
},
|
|
||||||
"diffRemoved": {
|
|
||||||
"dark": "nord11",
|
|
||||||
"light": "nord11"
|
|
||||||
},
|
|
||||||
"diffContext": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"diffHunkHeader": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"diffHighlightAdded": {
|
|
||||||
"dark": "nord14",
|
|
||||||
"light": "nord14"
|
|
||||||
},
|
|
||||||
"diffHighlightRemoved": {
|
|
||||||
"dark": "nord11",
|
|
||||||
"light": "nord11"
|
|
||||||
},
|
|
||||||
"diffAddedBg": {
|
|
||||||
"dark": "#36413C",
|
|
||||||
"light": "#E6EBE7"
|
|
||||||
},
|
|
||||||
"diffRemovedBg": {
|
|
||||||
"dark": "#43393D",
|
|
||||||
"light": "#ECE6E8"
|
|
||||||
},
|
|
||||||
"diffContextBg": {
|
|
||||||
"dark": "nord1",
|
|
||||||
"light": "nord5"
|
|
||||||
},
|
|
||||||
"diffLineNumber": {
|
|
||||||
"dark": "nord2",
|
|
||||||
"light": "nord4"
|
|
||||||
},
|
|
||||||
"diffAddedLineNumberBg": {
|
|
||||||
"dark": "#303A35",
|
|
||||||
"light": "#DDE4DF"
|
|
||||||
},
|
|
||||||
"diffRemovedLineNumberBg": {
|
|
||||||
"dark": "#3C3336",
|
|
||||||
"light": "#E4DDE0"
|
|
||||||
},
|
|
||||||
"markdownText": {
|
|
||||||
"dark": "nord4",
|
|
||||||
"light": "nord0"
|
|
||||||
},
|
|
||||||
"markdownHeading": {
|
|
||||||
"dark": "nord8",
|
|
||||||
"light": "nord10"
|
|
||||||
},
|
|
||||||
"markdownLink": {
|
|
||||||
"dark": "nord9",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"markdownLinkText": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"markdownCode": {
|
|
||||||
"dark": "nord14",
|
|
||||||
"light": "nord14"
|
|
||||||
},
|
|
||||||
"markdownBlockQuote": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"markdownEmph": {
|
|
||||||
"dark": "nord12",
|
|
||||||
"light": "nord12"
|
|
||||||
},
|
|
||||||
"markdownStrong": {
|
|
||||||
"dark": "nord13",
|
|
||||||
"light": "nord13"
|
|
||||||
},
|
|
||||||
"markdownHorizontalRule": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"markdownListItem": {
|
|
||||||
"dark": "nord8",
|
|
||||||
"light": "nord10"
|
|
||||||
},
|
|
||||||
"markdownListEnumeration": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"markdownImage": {
|
|
||||||
"dark": "nord9",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"markdownImageText": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"markdownCodeBlock": {
|
|
||||||
"dark": "nord4",
|
|
||||||
"light": "nord0"
|
|
||||||
},
|
|
||||||
"syntaxComment": {
|
|
||||||
"dark": "#8B95A7",
|
|
||||||
"light": "nord3"
|
|
||||||
},
|
|
||||||
"syntaxKeyword": {
|
|
||||||
"dark": "nord9",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"syntaxFunction": {
|
|
||||||
"dark": "nord8",
|
|
||||||
"light": "nord8"
|
|
||||||
},
|
|
||||||
"syntaxVariable": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"syntaxString": {
|
|
||||||
"dark": "nord14",
|
|
||||||
"light": "nord14"
|
|
||||||
},
|
|
||||||
"syntaxNumber": {
|
|
||||||
"dark": "nord15",
|
|
||||||
"light": "nord15"
|
|
||||||
},
|
|
||||||
"syntaxType": {
|
|
||||||
"dark": "nord7",
|
|
||||||
"light": "nord7"
|
|
||||||
},
|
|
||||||
"syntaxOperator": {
|
|
||||||
"dark": "nord9",
|
|
||||||
"light": "nord9"
|
|
||||||
},
|
|
||||||
"syntaxPunctuation": {
|
|
||||||
"dark": "nord4",
|
|
||||||
"light": "nord0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,937 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
|
||||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
|
||||||
import type {
|
|
||||||
TuiKeybindSet,
|
|
||||||
TuiPlugin,
|
|
||||||
TuiPluginApi,
|
|
||||||
TuiPluginMeta,
|
|
||||||
TuiPluginModule,
|
|
||||||
TuiSlotPlugin,
|
|
||||||
} from "@opencode-ai/plugin/tui"
|
|
||||||
|
|
||||||
const tabs = ["overview", "counter", "help"]
|
|
||||||
const bind = {
|
|
||||||
modal: "ctrl+shift+m",
|
|
||||||
screen: "ctrl+shift+o",
|
|
||||||
home: "escape,ctrl+h",
|
|
||||||
left: "left,h",
|
|
||||||
right: "right,l",
|
|
||||||
up: "up,k",
|
|
||||||
down: "down,j",
|
|
||||||
alert: "a",
|
|
||||||
confirm: "c",
|
|
||||||
prompt: "p",
|
|
||||||
select: "s",
|
|
||||||
modal_accept: "enter,return",
|
|
||||||
modal_close: "escape",
|
|
||||||
dialog_close: "escape",
|
|
||||||
local: "x",
|
|
||||||
local_push: "enter,return",
|
|
||||||
local_close: "q,backspace",
|
|
||||||
host: "z",
|
|
||||||
}
|
|
||||||
|
|
||||||
const pick = (value: unknown, fallback: string) => {
|
|
||||||
if (typeof value !== "string") return fallback
|
|
||||||
if (!value.trim()) return fallback
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = (value: unknown, fallback: number) => {
|
|
||||||
if (typeof value !== "number") return fallback
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const rec = (value: unknown) => {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
||||||
return Object.fromEntries(Object.entries(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cfg = {
|
|
||||||
label: string
|
|
||||||
route: string
|
|
||||||
vignette: number
|
|
||||||
keybinds: Record<string, unknown> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type Route = {
|
|
||||||
modal: string
|
|
||||||
screen: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
tab: number
|
|
||||||
count: number
|
|
||||||
source: string
|
|
||||||
note: string
|
|
||||||
selected: string
|
|
||||||
local: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
|
||||||
return {
|
|
||||||
label: pick(options?.label, "smoke"),
|
|
||||||
route: pick(options?.route, "workspace-smoke"),
|
|
||||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
|
||||||
keybinds: rec(options?.keybinds),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const names = (input: Cfg) => {
|
|
||||||
return {
|
|
||||||
modal: `${input.route}.modal`,
|
|
||||||
screen: `${input.route}.screen`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Keys = TuiKeybindSet
|
|
||||||
const ui = {
|
|
||||||
panel: "#1d1d1d",
|
|
||||||
border: "#4a4a4a",
|
|
||||||
text: "#f0f0f0",
|
|
||||||
muted: "#a5a5a5",
|
|
||||||
accent: "#5f87ff",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Color = RGBA | string
|
|
||||||
|
|
||||||
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
|
|
||||||
const value = map[name]
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
if (value instanceof RGBA) return value
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const look = (map: Record<string, unknown>) => {
|
|
||||||
return {
|
|
||||||
panel: ink(map, "backgroundPanel", ui.panel),
|
|
||||||
border: ink(map, "border", ui.border),
|
|
||||||
text: ink(map, "text", ui.text),
|
|
||||||
muted: ink(map, "textMuted", ui.muted),
|
|
||||||
accent: ink(map, "primary", ui.accent),
|
|
||||||
selected: ink(map, "selectedListItemText", ui.text),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tone = (api: TuiPluginApi) => {
|
|
||||||
return look(api.theme.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Skin = {
|
|
||||||
panel: Color
|
|
||||||
border: Color
|
|
||||||
text: Color
|
|
||||||
muted: Color
|
|
||||||
accent: Color
|
|
||||||
selected: Color
|
|
||||||
}
|
|
||||||
|
|
||||||
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
onMouseUp={() => {
|
|
||||||
props.run()
|
|
||||||
}}
|
|
||||||
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
>
|
|
||||||
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parse = (params: Record<string, unknown> | undefined) => {
|
|
||||||
const tab = typeof params?.tab === "number" ? params.tab : 0
|
|
||||||
const count = typeof params?.count === "number" ? params.count : 0
|
|
||||||
const source = typeof params?.source === "string" ? params.source : "unknown"
|
|
||||||
const note = typeof params?.note === "string" ? params.note : ""
|
|
||||||
const selected = typeof params?.selected === "string" ? params.selected : ""
|
|
||||||
const local = typeof params?.local === "number" ? params.local : 0
|
|
||||||
return {
|
|
||||||
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
|
||||||
count,
|
|
||||||
source,
|
|
||||||
note,
|
|
||||||
selected,
|
|
||||||
local: Math.max(0, local),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = (api: TuiPluginApi, route: Route) => {
|
|
||||||
const value = api.route.current
|
|
||||||
const ok = Object.values(route).includes(value.name)
|
|
||||||
if (!ok) return parse(undefined)
|
|
||||||
if (!("params" in value)) return parse(undefined)
|
|
||||||
return parse(value.params)
|
|
||||||
}
|
|
||||||
|
|
||||||
const opts = [
|
|
||||||
{
|
|
||||||
title: "Overview",
|
|
||||||
value: 0,
|
|
||||||
description: "Switch to overview tab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Counter",
|
|
||||||
value: 1,
|
|
||||||
description: "Switch to counter tab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Help",
|
|
||||||
value: 2,
|
|
||||||
description: "Switch to help tab",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
|
|
||||||
api.ui.dialog.setSize("medium")
|
|
||||||
api.ui.dialog.replace(() => (
|
|
||||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
|
||||||
<text fg={skin.text}>
|
|
||||||
<b>{input.label} host overlay</b>
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
|
||||||
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
const warn = (api: TuiPluginApi, route: Route, value: State) => {
|
|
||||||
const DialogAlert = api.ui.DialogAlert
|
|
||||||
api.ui.dialog.setSize("medium")
|
|
||||||
api.ui.dialog.replace(() => (
|
|
||||||
<DialogAlert
|
|
||||||
title="Smoke alert"
|
|
||||||
message="Testing built-in alert dialog"
|
|
||||||
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = (api: TuiPluginApi, route: Route, value: State) => {
|
|
||||||
const DialogConfirm = api.ui.DialogConfirm
|
|
||||||
api.ui.dialog.setSize("medium")
|
|
||||||
api.ui.dialog.replace(() => (
|
|
||||||
<DialogConfirm
|
|
||||||
title="Smoke confirm"
|
|
||||||
message="Apply +1 to counter?"
|
|
||||||
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
|
||||||
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = (api: TuiPluginApi, route: Route, value: State) => {
|
|
||||||
const DialogPrompt = api.ui.DialogPrompt
|
|
||||||
api.ui.dialog.setSize("medium")
|
|
||||||
api.ui.dialog.replace(() => (
|
|
||||||
<DialogPrompt
|
|
||||||
title="Smoke prompt"
|
|
||||||
value={value.note}
|
|
||||||
onConfirm={(note) => {
|
|
||||||
api.ui.dialog.clear()
|
|
||||||
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
api.ui.dialog.clear()
|
|
||||||
api.route.navigate(route.screen, value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
const picker = (api: TuiPluginApi, route: Route, value: State) => {
|
|
||||||
const DialogSelect = api.ui.DialogSelect
|
|
||||||
api.ui.dialog.setSize("medium")
|
|
||||||
api.ui.dialog.replace(() => (
|
|
||||||
<DialogSelect
|
|
||||||
title="Smoke select"
|
|
||||||
options={opts}
|
|
||||||
current={value.tab}
|
|
||||||
onSelect={(item) => {
|
|
||||||
api.ui.dialog.clear()
|
|
||||||
api.route.navigate(route.screen, {
|
|
||||||
...value,
|
|
||||||
tab: typeof item.value === "number" ? item.value : value.tab,
|
|
||||||
selected: item.title,
|
|
||||||
source: "select",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
const Screen = (props: {
|
|
||||||
api: TuiPluginApi
|
|
||||||
input: Cfg
|
|
||||||
route: Route
|
|
||||||
keys: Keys
|
|
||||||
meta: TuiPluginMeta
|
|
||||||
params?: Record<string, unknown>
|
|
||||||
}) => {
|
|
||||||
const dim = useTerminalDimensions()
|
|
||||||
const value = parse(props.params)
|
|
||||||
const skin = tone(props.api)
|
|
||||||
const set = (local: number, base?: State) => {
|
|
||||||
const next = base ?? current(props.api, props.route)
|
|
||||||
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
|
||||||
}
|
|
||||||
const push = (base?: State) => {
|
|
||||||
const next = base ?? current(props.api, props.route)
|
|
||||||
set(next.local + 1, next)
|
|
||||||
}
|
|
||||||
const open = () => {
|
|
||||||
const next = current(props.api, props.route)
|
|
||||||
if (next.local > 0) return
|
|
||||||
set(1, next)
|
|
||||||
}
|
|
||||||
const pop = (base?: State) => {
|
|
||||||
const next = base ?? current(props.api, props.route)
|
|
||||||
const local = Math.max(0, next.local - 1)
|
|
||||||
set(local, next)
|
|
||||||
}
|
|
||||||
const show = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
open()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
useKeyboard((evt) => {
|
|
||||||
if (props.api.route.current.name !== props.route.screen) return
|
|
||||||
const next = current(props.api, props.route)
|
|
||||||
if (props.api.ui.dialog.open) {
|
|
||||||
if (props.keys.match("dialog_close", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.ui.dialog.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.local > 0) {
|
|
||||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
pop(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("local_push", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
push(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("home", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate("home")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("left", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("right", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("up", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("down", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("modal", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.modal, next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("local", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
open()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("host", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
host(props.api, props.input, skin)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("alert", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
warn(props.api, props.route, next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("confirm", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
check(props.api, props.route, next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("prompt", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
entry(props.api, props.route, next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("select", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
picker(props.api, props.route, next)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={2}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
|
||||||
<text fg={skin.text}>
|
|
||||||
<b>{props.input.label} screen</b>
|
|
||||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
|
||||||
{tabs.map((item, i) => {
|
|
||||||
const on = value.tab === i
|
|
||||||
return (
|
|
||||||
<Btn
|
|
||||||
txt={item}
|
|
||||||
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
|
||||||
skin={skin}
|
|
||||||
on={on}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={skin.border}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={2}
|
|
||||||
flexGrow={1}
|
|
||||||
>
|
|
||||||
{value.tab === 0 ? (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={skin.text}>Route: {props.route.screen}</text>
|
|
||||||
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
|
||||||
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
|
|
||||||
<text fg={skin.muted}>source: {value.source}</text>
|
|
||||||
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
|
||||||
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
|
||||||
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
|
||||||
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
|
||||||
</box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{value.tab === 1 ? (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={skin.text}>Counter: {value.count}</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{value.tab === 2 ? (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
|
||||||
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
|
||||||
close
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
|
||||||
</box>
|
|
||||||
) : null}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} paddingTop={1}>
|
|
||||||
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
|
||||||
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
|
||||||
<Btn txt="local overlay" run={show} skin={skin} />
|
|
||||||
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
|
||||||
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
|
||||||
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
|
||||||
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
|
||||||
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
visible={value.local > 0}
|
|
||||||
width={dim().width}
|
|
||||||
height={dim().height}
|
|
||||||
alignItems="center"
|
|
||||||
position="absolute"
|
|
||||||
zIndex={3000}
|
|
||||||
paddingTop={dim().height / 4}
|
|
||||||
left={0}
|
|
||||||
top={0}
|
|
||||||
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
|
||||||
onMouseUp={() => {
|
|
||||||
pop()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
onMouseUp={(evt) => {
|
|
||||||
evt.stopPropagation()
|
|
||||||
}}
|
|
||||||
width={60}
|
|
||||||
maxWidth={dim().width - 2}
|
|
||||||
backgroundColor={skin.panel}
|
|
||||||
border
|
|
||||||
borderColor={skin.border}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={2}
|
|
||||||
gap={1}
|
|
||||||
flexDirection="column"
|
|
||||||
>
|
|
||||||
<text fg={skin.text}>
|
|
||||||
<b>{props.input.label} local overlay</b>
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Btn txt="push" run={push} skin={skin} on />
|
|
||||||
<Btn txt="pop" run={pop} skin={skin} />
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Modal = (props: {
|
|
||||||
api: TuiPluginApi
|
|
||||||
input: Cfg
|
|
||||||
route: Route
|
|
||||||
keys: Keys
|
|
||||||
params?: Record<string, unknown>
|
|
||||||
}) => {
|
|
||||||
const Dialog = props.api.ui.Dialog
|
|
||||||
const value = parse(props.params)
|
|
||||||
const skin = tone(props.api)
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
|
||||||
if (props.api.route.current.name !== props.route.modal) return
|
|
||||||
|
|
||||||
if (props.keys.match("modal_accept", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.keys.match("modal_close", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
props.api.route.navigate("home")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
|
||||||
<Dialog onClose={() => props.api.route.navigate("home")}>
|
|
||||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
|
||||||
<text fg={skin.text}>
|
|
||||||
<b>{props.input.label} modal</b>
|
|
||||||
</text>
|
|
||||||
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
|
||||||
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Btn
|
|
||||||
txt="open screen"
|
|
||||||
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
|
||||||
skin={skin}
|
|
||||||
on
|
|
||||||
/>
|
|
||||||
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Dialog>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const home = (api: TuiPluginApi, input: Cfg) => ({
|
|
||||||
slots: {
|
|
||||||
home_logo(ctx) {
|
|
||||||
const map = ctx.theme.current
|
|
||||||
const skin = look(map)
|
|
||||||
const art = [
|
|
||||||
" $$\\",
|
|
||||||
" $$ |",
|
|
||||||
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
|
||||||
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
|
||||||
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
|
||||||
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
|
||||||
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
|
||||||
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
|
||||||
]
|
|
||||||
const fill = [
|
|
||||||
skin.accent,
|
|
||||||
skin.muted,
|
|
||||||
ink(map, "info", ui.accent),
|
|
||||||
skin.text,
|
|
||||||
ink(map, "success", ui.accent),
|
|
||||||
ink(map, "warning", ui.accent),
|
|
||||||
ink(map, "secondary", ui.accent),
|
|
||||||
ink(map, "error", ui.accent),
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column">
|
|
||||||
{art.map((line, i) => (
|
|
||||||
<text fg={fill[i]}>{line}</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
home_prompt(ctx, value) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
type Prompt = (props: {
|
|
||||||
workspaceID?: string
|
|
||||||
visible?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onSubmit?: () => void
|
|
||||||
hint?: JSX.Element
|
|
||||||
right?: JSX.Element
|
|
||||||
showPlaceholder?: boolean
|
|
||||||
placeholders?: {
|
|
||||||
normal?: string[]
|
|
||||||
shell?: string[]
|
|
||||||
}
|
|
||||||
}) => JSX.Element
|
|
||||||
type Slot = (
|
|
||||||
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
|
||||||
) => JSX.Element | null
|
|
||||||
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
|
||||||
const Prompt = ui.Prompt
|
|
||||||
const Slot = ui.Slot
|
|
||||||
const normal = [
|
|
||||||
`[SMOKE] route check for ${input.label}`,
|
|
||||||
"[SMOKE] confirm home_prompt slot override",
|
|
||||||
"[SMOKE] verify prompt-right slot passthrough",
|
|
||||||
]
|
|
||||||
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
|
||||||
const hint = (
|
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Prompt
|
|
||||||
workspaceID={value.workspace_id}
|
|
||||||
hint={hint}
|
|
||||||
right={
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
|
|
||||||
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
placeholders={{ normal, shell }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
home_prompt_right(ctx, value) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
const id = value.workspace_id?.slice(0, 8) ?? "none"
|
|
||||||
return (
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
session_prompt_right(ctx, value) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
return (
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
smoke_prompt_right(ctx, value) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
|
|
||||||
const label = typeof value.label === "string" ? value.label : input.label
|
|
||||||
return (
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
home_bottom(ctx) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
const text = "extra content in the unified home bottom slot"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={skin.border}
|
|
||||||
backgroundColor={skin.panel}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={2}
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
|
|
||||||
order,
|
|
||||||
slots: {
|
|
||||||
sidebar_content(ctx, value) {
|
|
||||||
const skin = look(ctx.theme.current)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={skin.border}
|
|
||||||
backgroundColor={skin.panel}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={2}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
|
||||||
>
|
|
||||||
<text fg={skin.accent}>
|
|
||||||
<b>{title}</b>
|
|
||||||
</text>
|
|
||||||
<text fg={skin.text}>{text}</text>
|
|
||||||
<text fg={skin.muted}>
|
|
||||||
{input.label} order {order} · session {value.session_id.slice(0, 8)}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
|
|
||||||
home(api, input),
|
|
||||||
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
|
|
||||||
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
|
|
||||||
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
|
|
||||||
]
|
|
||||||
|
|
||||||
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
|
||||||
const route = names(input)
|
|
||||||
api.command.register(() => [
|
|
||||||
{
|
|
||||||
title: `${input.label} modal`,
|
|
||||||
value: "plugin.smoke.modal",
|
|
||||||
keybind: keys.get("modal"),
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
api.route.navigate(route.modal, { source: "command" })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} screen`,
|
|
||||||
value: "plugin.smoke.screen",
|
|
||||||
keybind: keys.get("screen"),
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-screen",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} alert dialog`,
|
|
||||||
value: "plugin.smoke.alert",
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-alert",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
warn(api, route, current(api, route))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} confirm dialog`,
|
|
||||||
value: "plugin.smoke.confirm",
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-confirm",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
check(api, route, current(api, route))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} prompt dialog`,
|
|
||||||
value: "plugin.smoke.prompt",
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-prompt",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
entry(api, route, current(api, route))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} select dialog`,
|
|
||||||
value: "plugin.smoke.select",
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-select",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
picker(api, route, current(api, route))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} host overlay`,
|
|
||||||
value: "plugin.smoke.host",
|
|
||||||
category: "Plugin",
|
|
||||||
slash: {
|
|
||||||
name: "smoke-host",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
host(api, input, tone(api))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} go home`,
|
|
||||||
value: "plugin.smoke.home",
|
|
||||||
category: "Plugin",
|
|
||||||
enabled: api.route.current.name !== "home",
|
|
||||||
onSelect: () => {
|
|
||||||
api.route.navigate("home")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `${input.label} toast`,
|
|
||||||
value: "plugin.smoke.toast",
|
|
||||||
category: "Plugin",
|
|
||||||
onSelect: () => {
|
|
||||||
api.ui.toast({
|
|
||||||
variant: "info",
|
|
||||||
title: "Smoke",
|
|
||||||
message: "Plugin toast works",
|
|
||||||
duration: 2000,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const tui: TuiPlugin = async (api, options, meta) => {
|
|
||||||
if (options?.enabled === false) return
|
|
||||||
|
|
||||||
await api.theme.install("./smoke-theme.json")
|
|
||||||
api.theme.set("smoke-theme")
|
|
||||||
|
|
||||||
const value = cfg(options ?? undefined)
|
|
||||||
const route = names(value)
|
|
||||||
const keys = api.keybind.create(bind, value.keybinds)
|
|
||||||
const fx = new VignetteEffect(value.vignette)
|
|
||||||
const post = fx.apply.bind(fx)
|
|
||||||
api.renderer.addPostProcessFn(post)
|
|
||||||
api.lifecycle.onDispose(() => {
|
|
||||||
api.renderer.removePostProcessFn(post)
|
|
||||||
})
|
|
||||||
|
|
||||||
api.route.register([
|
|
||||||
{
|
|
||||||
name: route.screen,
|
|
||||||
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: route.modal,
|
|
||||||
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
reg(api, value, keys)
|
|
||||||
for (const item of slot(api, value)) {
|
|
||||||
api.slots.register(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
|
||||||
id: "tui-smoke",
|
|
||||||
tui,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default plugin
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
smoke-theme.json
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/tui.json",
|
|
||||||
"plugin": [
|
|
||||||
[
|
|
||||||
"./plugins/tui-smoke.tsx",
|
|
||||||
{
|
|
||||||
"enabled": false,
|
|
||||||
"label": "workspace",
|
|
||||||
"keybinds": {
|
|
||||||
"modal": "ctrl+alt+m",
|
|
||||||
"screen": "ctrl+alt+o",
|
|
||||||
"home": "escape,ctrl+shift+h",
|
|
||||||
"dialog_close": "escape,q"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
github-policies:
|
||||||
|
runners:
|
||||||
|
allowed_groups:
|
||||||
|
- "GitHub Actions"
|
||||||
|
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||||
@@ -109,12 +109,6 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
|
|||||||
appliesToProducts: [zenLiteProduct.id],
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
duration: "once",
|
duration: "once",
|
||||||
})
|
})
|
||||||
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
|
|
||||||
name: "First month 100% off",
|
|
||||||
percentOff: 100,
|
|
||||||
appliesToProducts: [zenLiteProduct.id],
|
|
||||||
duration: "once",
|
|
||||||
})
|
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -130,7 +124,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
priceInr: 92900,
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -236,7 +229,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
|
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
|
||||||
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
|
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
|
||||||
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
|
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
|
||||||
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
|
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ let
|
|||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "opencode-node_modules";
|
pname = "opencode-node_modules";
|
||||||
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
|
version = "${packageJson.version}-${rev}";
|
||||||
|
|
||||||
src = lib.fileset.toSource {
|
src = lib.fileset.toSource {
|
||||||
root = ../.;
|
root = ../.;
|
||||||
@@ -54,7 +54,6 @@ stdenvNoCC.mkDerivation {
|
|||||||
--filter '!./' \
|
--filter '!./' \
|
||||||
--filter './packages/opencode' \
|
--filter './packages/opencode' \
|
||||||
--filter './packages/desktop' \
|
--filter './packages/desktop' \
|
||||||
--filter './packages/app' \
|
|
||||||
--frozen-lockfile \
|
--frozen-lockfile \
|
||||||
--ignore-scripts \
|
--ignore-scripts \
|
||||||
--no-progress
|
--no-progress
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
stdenvNoCC,
|
stdenvNoCC,
|
||||||
callPackage,
|
callPackage,
|
||||||
bun,
|
bun,
|
||||||
nodejs,
|
|
||||||
sysctl,
|
sysctl,
|
||||||
makeBinaryWrapper,
|
makeBinaryWrapper,
|
||||||
models-dev,
|
models-dev,
|
||||||
@@ -20,7 +19,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
bun
|
bun
|
||||||
nodejs # for patchShebangs node_modules
|
|
||||||
installShellFiles
|
installShellFiles
|
||||||
makeBinaryWrapper
|
makeBinaryWrapper
|
||||||
models-dev
|
models-dev
|
||||||
@@ -31,8 +29,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
runHook preConfigure
|
runHook preConfigure
|
||||||
|
|
||||||
cp -R ${finalAttrs.node_modules}/. .
|
cp -R ${finalAttrs.node_modules}/. .
|
||||||
patchShebangs node_modules
|
|
||||||
patchShebangs packages/*/node_modules
|
|
||||||
|
|
||||||
runHook postConfigure
|
runHook postConfigure
|
||||||
'';
|
'';
|
||||||
|
|||||||
+6
-12
@@ -12,7 +12,6 @@
|
|||||||
"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:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
@@ -26,9 +25,8 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.46",
|
"@effect/platform-node": "4.0.0-beta.35",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
"@types/cross-spawn": "6.0.6",
|
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -47,16 +45,14 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.46",
|
"effect": "4.0.0-beta.35",
|
||||||
"ai": "6.0.158",
|
"ai": "5.0.124",
|
||||||
"cross-spawn": "7.0.6",
|
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.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.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
|
||||||
"@playwright/test": "1.51.0",
|
"@playwright/test": "1.51.0",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
@@ -71,8 +67,7 @@
|
|||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10"
|
||||||
"@lydell/node-pty": "1.2.0-beta.10"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -92,7 +87,6 @@
|
|||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"heap-snapshot-toolkit": "1.1.3",
|
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -106,11 +100,9 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"node-pty",
|
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"tree-sitter-powershell",
|
|
||||||
"web-tree-sitter",
|
"web-tree-sitter",
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
@@ -120,6 +112,8 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@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",
|
||||||
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
### Using Fixtures
|
### Using Fixtures
|
||||||
|
|
||||||
- `page` - Playwright page
|
- `page` - Playwright page
|
||||||
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
- `sdk` - OpenCode SDK client for API calls
|
||||||
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
- `gotoSession(sessionID?)` - Navigate to session
|
||||||
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
|
||||||
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
@@ -75,9 +73,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `withProject(...)` - Create temp project/workspace
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
|
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||||
|
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -127,9 +128,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
- Prefer `withSession(...)` for temp sessions
|
||||||
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||||
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|||||||
+157
-88
@@ -7,6 +7,7 @@ import { execSync } from "node:child_process"
|
|||||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectSwitchSelector,
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
@@ -205,7 +206,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
@@ -236,7 +237,7 @@ async function errorBoundaryText(page: Page) {
|
|||||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertHealthy(page: Page, context: string) {
|
export async function assertHealthy(page: Page, context: string) {
|
||||||
const text = await errorBoundaryText(page)
|
const text = await errorBoundaryText(page)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
@@ -311,7 +312,61 @@ export async function openSettings(page: Page) {
|
|||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = nextProjects[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = [args.directory, ...args.extra]
|
||||||
|
for (const directory of directories) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
list,
|
||||||
|
projects: nextProjects,
|
||||||
|
lastProject,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestProject() {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
@@ -320,14 +375,13 @@ export async function createTestProject(input?: { serverUrl?: string }) {
|
|||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
|
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return resolveDirectory(root, input?.serverUrl)
|
return resolveDirectory(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
@@ -376,22 +430,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
export async function resolveSlug(slug: string) {
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
const resolved = await resolveDirectory(directory)
|
||||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
export async function waitDir(page: Page, directory: string) {
|
||||||
const target = await resolveDirectory(directory, input?.serverUrl)
|
const target = await resolveDirectory(directory)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitDir")
|
await assertHealthy(page, "waitDir")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug, input)
|
return resolveSlug(slug)
|
||||||
.then((item) => item.directory)
|
.then((item) => item.directory)
|
||||||
.catch(() => "")
|
.catch(() => "")
|
||||||
},
|
},
|
||||||
@@ -401,33 +455,22 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(
|
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||||
page: Page,
|
const target = await resolveDirectory(input.directory)
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
sessionID?: string
|
|
||||||
serverUrl?: string
|
|
||||||
allowAnySession?: boolean
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitSession")
|
await assertHealthy(page, "waitSession")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return false
|
if (!slug) return false
|
||||||
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
|
||||||
if (!input.sessionID && !input.allowAnySession && current) return false
|
|
||||||
|
|
||||||
const state = await probeSession(page)
|
const state = await probeSession(page)
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,9 +486,9 @@ export async function waitSession(
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||||
const sdk = createSdk(directory, serverUrl)
|
const sdk = createSdk(directory)
|
||||||
const target = await resolveDirectory(directory, serverUrl)
|
const target = await resolveDirectory(directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -455,7 +498,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
|||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!data?.directory) return ""
|
if (!data?.directory) return ""
|
||||||
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||||
},
|
},
|
||||||
{ timeout },
|
{ timeout },
|
||||||
)
|
)
|
||||||
@@ -532,15 +575,12 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSharePopover(page: Page) {
|
export async function openSharePopover(page: Page) {
|
||||||
const scroller = page.locator(".scroll-view__viewport").first()
|
const rightSection = page.locator(titlebarRightSelector)
|
||||||
await expect(scroller).toBeVisible()
|
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
await expect(shareButton).toBeVisible()
|
||||||
|
|
||||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
|
||||||
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const popoverBody = page
|
const popoverBody = page
|
||||||
.locator('[data-component="popover-content"]')
|
.locator(popoverBodySelector)
|
||||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
@@ -550,13 +590,16 @@ export async function openSharePopover(page: Page) {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
await shareButton.click()
|
||||||
await menuTrigger.click()
|
await expect(popoverBody).toBeVisible()
|
||||||
await clickMenuItem(menu, /share/i)
|
|
||||||
await expect(menu).toHaveCount(0)
|
|
||||||
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
|
||||||
}
|
}
|
||||||
return { rightSection: scroller, popoverBody }
|
return { rightSection, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||||
|
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickListItem(
|
export async function clickListItem(
|
||||||
@@ -620,9 +663,8 @@ export async function cleanupSession(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sdk?: ReturnType<typeof createSdk>
|
sdk?: ReturnType<typeof createSdk>
|
||||||
serverUrl?: string
|
|
||||||
}) {
|
}) {
|
||||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
@@ -724,6 +766,40 @@ export async function seedSessionQuestion(
|
|||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedSessionPermission(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
description?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one bash tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
||||||
|
workdir: "/",
|
||||||
|
description: input.description ?? `seed ${input.permission} permission request`,
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding permission request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedSessionTask(
|
export async function seedSessionTask(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -782,6 +858,36 @@ export async function seedSessionTask(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTodos(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<{ content: string; status: string; priority: string }>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one todowrite tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
const target = JSON.stringify(input.todos)
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
||||||
|
if (JSON.stringify(todos) !== target) return
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding todos")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
const [questions, permissions] = await Promise.all([
|
const [questions, permissions] = await Promise.all([
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
@@ -871,57 +977,30 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
const current = () =>
|
const current = await page
|
||||||
page
|
|
||||||
.getByRole("button", { name: "New workspace" })
|
.getByRole("button", { name: "New workspace" })
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.then((x) => x)
|
.then((x) => x)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if ((await current()) === enabled) return
|
if (current === enabled) return
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
await page.reload()
|
|
||||||
await openSidebar(page)
|
|
||||||
if ((await current()) === enabled) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
const flip = async (timeout?: number) => {
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
return toggle.click({ force: true, timeout })
|
||||||
const clicked = await toggle
|
|
||||||
.click({ force: true, timeout })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (clicked) return
|
|
||||||
await toggle.focus()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const timeout of [1500, undefined, undefined]) {
|
const flipped = await flip(1500)
|
||||||
if ((await current()) === enabled) break
|
|
||||||
await flip(timeout)
|
|
||||||
.then(() => undefined)
|
|
||||||
.catch(() => undefined)
|
|
||||||
const matched = await expect
|
|
||||||
.poll(current, { timeout: 5_000 })
|
|
||||||
.toBe(enabled)
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
if (matched) break
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((await current()) !== enabled) {
|
if (!flipped) await flip()
|
||||||
await page.reload()
|
|
||||||
await openSidebar(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
@@ -937,13 +1016,3 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { spawn } from "node:child_process"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import net from "node:net"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
type Handle = {
|
|
||||||
url: string
|
|
||||||
stop: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function freePort() {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.listen(0, () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) reject(err)
|
|
||||||
else resolve(address.port)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHealth(url: string, probe = "/global/health") {
|
|
||||||
const end = Date.now() + 120_000
|
|
||||||
let last = ""
|
|
||||||
while (Date.now() < end) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}${probe}`)
|
|
||||||
if (res.ok) return
|
|
||||||
last = `status ${res.status}`
|
|
||||||
} catch (err) {
|
|
||||||
last = err instanceof Error ? err.message : String(err)
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function done(proc: ReturnType<typeof spawn>) {
|
|
||||||
return proc.exitCode !== null || proc.signalCode !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
|
||||||
if (done(proc)) return
|
|
||||||
await Promise.race([
|
|
||||||
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_CAP = 100
|
|
||||||
|
|
||||||
function cap(input: string[]) {
|
|
||||||
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tail(input: string[]) {
|
|
||||||
return input.slice(-40).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
|
||||||
const port = await freePort()
|
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
|
||||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
||||||
const repoDir = path.resolve(appDir, "../..")
|
|
||||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
|
||||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
|
||||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
|
||||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
|
||||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
|
||||||
OPENCODE_CLIENT: "app",
|
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
|
||||||
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
|
||||||
} satisfies Record<string, string | undefined>
|
|
||||||
const out: string[] = []
|
|
||||||
const err: string[] = []
|
|
||||||
const proc = spawn(
|
|
||||||
"bun",
|
|
||||||
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
|
||||||
{
|
|
||||||
cwd: opencodeDir,
|
|
||||||
env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
proc.stdout?.on("data", (chunk) => {
|
|
||||||
out.push(String(chunk))
|
|
||||||
cap(out)
|
|
||||||
})
|
|
||||||
proc.stderr?.on("data", (chunk) => {
|
|
||||||
err.push(String(chunk))
|
|
||||||
cap(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `http://127.0.0.1:${port}`
|
|
||||||
try {
|
|
||||||
await waitForHealth(url)
|
|
||||||
} catch (error) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Failed to start isolated e2e backend for ${label}`,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
tail(out),
|
|
||||||
tail(err),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
async stop() {
|
|
||||||
if (!done(proc)) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await waitExit(proc)
|
|
||||||
}
|
|
||||||
if (!done(proc)) {
|
|
||||||
proc.kill("SIGKILL")
|
|
||||||
await waitExit(proc)
|
|
||||||
}
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+65
-515
@@ -1,251 +1,41 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { ManagedRuntime } from "effect"
|
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { startBackend } from "./backend"
|
|
||||||
import {
|
import {
|
||||||
healthPhase,
|
healthPhase,
|
||||||
cleanupSession,
|
cleanupSession,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
createTestProject,
|
createTestProject,
|
||||||
setHealthPhase,
|
setHealthPhase,
|
||||||
|
seedProjects,
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
|
waitSession,
|
||||||
} from "./actions"
|
} from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
|
||||||
|
|
||||||
type LLMFixture = {
|
|
||||||
url: string
|
|
||||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
|
||||||
pushMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
...input: (Item | Reply)[]
|
|
||||||
) => Promise<void>
|
|
||||||
textMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
value: string,
|
|
||||||
opts?: { usage?: Usage },
|
|
||||||
) => Promise<void>
|
|
||||||
toolMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
name: string,
|
|
||||||
input: unknown,
|
|
||||||
) => Promise<void>
|
|
||||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
|
||||||
tool: (name: string, input: unknown) => Promise<void>
|
|
||||||
toolHang: (name: string, input: unknown) => Promise<void>
|
|
||||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
|
||||||
fail: (message?: unknown) => Promise<void>
|
|
||||||
error: (status: number, body: unknown) => Promise<void>
|
|
||||||
hang: () => Promise<void>
|
|
||||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
|
||||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
|
||||||
calls: () => Promise<number>
|
|
||||||
wait: (count: number) => Promise<void>
|
|
||||||
inputs: () => Promise<Record<string, unknown>[]>
|
|
||||||
pending: () => Promise<number>
|
|
||||||
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
|
||||||
}
|
|
||||||
|
|
||||||
type LLMWorker = LLMFixture & {
|
|
||||||
reset: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type AssistantFixture = {
|
|
||||||
reply: LLMFixture["text"]
|
|
||||||
tool: LLMFixture["tool"]
|
|
||||||
toolHang: LLMFixture["toolHang"]
|
|
||||||
reason: LLMFixture["reason"]
|
|
||||||
fail: LLMFixture["fail"]
|
|
||||||
error: LLMFixture["error"]
|
|
||||||
hang: LLMFixture["hang"]
|
|
||||||
hold: LLMFixture["hold"]
|
|
||||||
calls: LLMFixture["calls"]
|
|
||||||
pending: LLMFixture["pending"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
const seedModel = (() => {
|
type TestFixtures = {
|
||||||
const [providerID = "opencode", modelID = "big-pickle"] = (
|
sdk: ReturnType<typeof createSdk>
|
||||||
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
).split("/")
|
withProject: <T>(
|
||||||
return {
|
callback: (project: {
|
||||||
providerID: providerID || "opencode",
|
|
||||||
modelID: modelID || "big-pickle",
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
function clean(value: string | null) {
|
|
||||||
return (value ?? "").replace(/\u200B/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function visit(page: Page, url: string) {
|
|
||||||
let err: unknown
|
|
||||||
for (const _ of [0, 1, 2]) {
|
|
||||||
try {
|
|
||||||
await page.goto(url)
|
|
||||||
return
|
|
||||||
} catch (cause) {
|
|
||||||
err = cause
|
|
||||||
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptSend(page: Page) {
|
|
||||||
return page
|
|
||||||
.evaluate(() => {
|
|
||||||
const win = window as E2EWindow
|
|
||||||
const sent = win.__opencode_e2e?.prompt?.sent
|
|
||||||
return {
|
|
||||||
started: sent?.started ?? 0,
|
|
||||||
count: sent?.count ?? 0,
|
|
||||||
sessionID: sent?.sessionID,
|
|
||||||
directory: sent?.directory,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectHandle = {
|
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
trackDirectory: (directory: string) => void
|
trackDirectory: (directory: string) => void
|
||||||
sdk: ReturnType<typeof createSdk>
|
}) => Promise<T>,
|
||||||
}
|
options?: { extra?: string[] },
|
||||||
|
) => Promise<T>
|
||||||
type ProjectOptions = {
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
setup?: (directory: string) => Promise<void>
|
|
||||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectFixture = ProjectHandle & {
|
|
||||||
open: (options?: ProjectOptions) => Promise<void>
|
|
||||||
prompt: (text: string) => Promise<string>
|
|
||||||
user: (text: string) => Promise<string>
|
|
||||||
shell: (cmd: string) => Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestFixtures = {
|
|
||||||
llm: LLMFixture
|
|
||||||
assistant: AssistantFixture
|
|
||||||
project: ProjectFixture
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
_llm: LLMWorker
|
|
||||||
backend: {
|
|
||||||
url: string
|
|
||||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
}
|
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
_llm: [
|
|
||||||
async ({}, use) => {
|
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
|
||||||
try {
|
|
||||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
|
||||||
await use({
|
|
||||||
url: svc.url,
|
|
||||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
|
||||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
|
||||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
|
||||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
|
||||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
|
||||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
|
||||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
|
||||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
|
||||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
|
||||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
|
||||||
hang: () => rt.runPromise(svc.hang),
|
|
||||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
|
||||||
reset: () => rt.runPromise(svc.reset),
|
|
||||||
hits: () => rt.runPromise(svc.hits),
|
|
||||||
calls: () => rt.runPromise(svc.calls),
|
|
||||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
|
||||||
inputs: () => rt.runPromise(svc.inputs),
|
|
||||||
pending: () => rt.runPromise(svc.pending),
|
|
||||||
misses: () => rt.runPromise(svc.misses),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
backend: [
|
|
||||||
async ({ _llm }, use, workerInfo) => {
|
|
||||||
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
|
||||||
try {
|
|
||||||
await use({
|
|
||||||
url: handle.url,
|
|
||||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await handle.stop()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
llm: async ({ _llm }, use) => {
|
|
||||||
await _llm.reset()
|
|
||||||
await use({
|
|
||||||
url: _llm.url,
|
|
||||||
push: _llm.push,
|
|
||||||
pushMatch: _llm.pushMatch,
|
|
||||||
textMatch: _llm.textMatch,
|
|
||||||
toolMatch: _llm.toolMatch,
|
|
||||||
text: _llm.text,
|
|
||||||
tool: _llm.tool,
|
|
||||||
toolHang: _llm.toolHang,
|
|
||||||
reason: _llm.reason,
|
|
||||||
fail: _llm.fail,
|
|
||||||
error: _llm.error,
|
|
||||||
hang: _llm.hang,
|
|
||||||
hold: _llm.hold,
|
|
||||||
hits: _llm.hits,
|
|
||||||
calls: _llm.calls,
|
|
||||||
wait: _llm.wait,
|
|
||||||
inputs: _llm.inputs,
|
|
||||||
pending: _llm.pending,
|
|
||||||
misses: _llm.misses,
|
|
||||||
})
|
|
||||||
const pending = await _llm.pending()
|
|
||||||
if (pending > 0) {
|
|
||||||
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
assistant: async ({ llm }, use) => {
|
|
||||||
await use({
|
|
||||||
reply: llm.text,
|
|
||||||
tool: llm.tool,
|
|
||||||
toolHang: llm.toolHang,
|
|
||||||
reason: llm.reason,
|
|
||||||
fail: llm.fail,
|
|
||||||
error: llm.error,
|
|
||||||
hang: llm.hang,
|
|
||||||
hold: llm.hold,
|
|
||||||
calls: llm.calls,
|
|
||||||
pending: llm.pending,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
let boundary: string | undefined
|
let boundary: string | undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
@@ -270,8 +60,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
if (boundary) throw new Error(boundary)
|
if (boundary) throw new Error(boundary)
|
||||||
},
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({ backend }, use) => {
|
async ({}, use) => {
|
||||||
await use(await getWorktree(backend.url))
|
const directory = await getWorktree()
|
||||||
|
await use(directory)
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
@@ -281,324 +72,83 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
sdk: async ({ directory, backend }, use) => {
|
sdk: async ({ directory }, use) => {
|
||||||
await use(backend.sdk(directory))
|
await use(createSdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory, backend }, use) => {
|
gotoSession: async ({ page, directory }, use) => {
|
||||||
await seedStorage(page, { directory, serverUrl: backend.url })
|
await seedStorage(page, { directory })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await visit(page, sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await waitSession(page, {
|
await waitSession(page, { directory, sessionID })
|
||||||
directory,
|
|
||||||
sessionID,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
allowAnySession: !sessionID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
project: async ({ page, llm, backend }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
const item = makeProject(page, llm, backend)
|
await use(async (callback, options) => {
|
||||||
try {
|
const root = await createTestProject()
|
||||||
await use(item.project)
|
const sessions = new Map<string, string>()
|
||||||
} finally {
|
const dirs = new Set<string>()
|
||||||
await item.cleanup()
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function makeProject(
|
|
||||||
page: Page,
|
|
||||||
llm: LLMFixture,
|
|
||||||
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
|
||||||
) {
|
|
||||||
let state:
|
|
||||||
| {
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
sessions: Map<string, string>
|
|
||||||
dirs: Set<string>
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
const need = () => {
|
|
||||||
if (state) return state
|
|
||||||
throw new Error("project.open() must be called first")
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
|
||||||
const cur = need()
|
|
||||||
cur.sessions.set(sessionID, directory ?? cur.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
const cur = need()
|
|
||||||
if (directory !== cur.directory) cur.dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
const cur = need()
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await visit(page, sessionPath(cur.directory, sessionID))
|
await waitSession(page, { directory: root, sessionID })
|
||||||
await waitSession(page, {
|
|
||||||
directory: cur.directory,
|
|
||||||
sessionID,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
allowAnySession: !sessionID,
|
|
||||||
})
|
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = async (options?: ProjectOptions) => {
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
if (state) return
|
sessions.set(sessionID, directory ?? root)
|
||||||
const directory = await createTestProject({ serverUrl: backend.url })
|
|
||||||
const sdk = backend.sdk(directory)
|
|
||||||
await options?.setup?.(directory)
|
|
||||||
await seedStorage(page, {
|
|
||||||
directory,
|
|
||||||
extra: options?.extra,
|
|
||||||
model: options?.model,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
})
|
|
||||||
state = {
|
|
||||||
directory,
|
|
||||||
slug: "",
|
|
||||||
sdk,
|
|
||||||
sessions: new Map(),
|
|
||||||
dirs: new Set(),
|
|
||||||
}
|
}
|
||||||
await options?.beforeGoto?.({ directory, sdk })
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
if (directory !== root) dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
need().slug = await waitSlug(page)
|
const slug = await waitSlug(page)
|
||||||
}
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
|
} finally {
|
||||||
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
|
||||||
if (input.noReply) {
|
|
||||||
const cur = need()
|
|
||||||
const state = await page.evaluate(() => {
|
|
||||||
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
|
||||||
if (!model) return null
|
|
||||||
return {
|
|
||||||
dir: model.dir,
|
|
||||||
sessionID: model.sessionID,
|
|
||||||
agent: model.agent,
|
|
||||||
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
|
||||||
variant: model.variant ?? undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const dir = state?.dir ?? cur.directory
|
|
||||||
const sdk = backend.sdk(dir)
|
|
||||||
const sessionID = state?.sessionID
|
|
||||||
? state.sessionID
|
|
||||||
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
|
||||||
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
|
||||||
return res.data.id
|
|
||||||
})
|
|
||||||
await sdk.session.prompt({
|
|
||||||
sessionID,
|
|
||||||
agent: state?.agent,
|
|
||||||
model: state?.model,
|
|
||||||
variant: state?.variant,
|
|
||||||
noReply: true,
|
|
||||||
parts: [{ type: "text", text }],
|
|
||||||
})
|
|
||||||
await visit(page, sessionPath(dir, sessionID))
|
|
||||||
const active = await waitSession(page, {
|
|
||||||
directory: dir,
|
|
||||||
sessionID,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
})
|
|
||||||
trackSession(sessionID, active.directory)
|
|
||||||
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
|
||||||
return sessionID
|
|
||||||
}
|
|
||||||
|
|
||||||
const prev = await promptSend(page)
|
|
||||||
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
|
||||||
await llm.text("ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector).first()
|
|
||||||
const submit = async () => {
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await prompt.click()
|
|
||||||
if (input.shell) {
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
|
||||||
}
|
|
||||||
await page.keyboard.type(text)
|
|
||||||
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
const started = await expect
|
|
||||||
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
|
||||||
.toBeGreaterThan(prev.started)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (started) return
|
|
||||||
const send = page.getByRole("button", { name: "Send" }).first()
|
|
||||||
const enabled = await send
|
|
||||||
.isEnabled()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (enabled) {
|
|
||||||
await send.click()
|
|
||||||
} else {
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
}
|
|
||||||
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
|
||||||
}
|
|
||||||
|
|
||||||
await submit()
|
|
||||||
|
|
||||||
let next: { sessionID: string; directory: string } | undefined
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const sent = await promptSend(page)
|
|
||||||
if (sent.count <= prev.count) return ""
|
|
||||||
if (!sent.sessionID || !sent.directory) return ""
|
|
||||||
next = { sessionID: sent.sessionID, directory: sent.directory }
|
|
||||||
return sent.sessionID
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
|
||||||
const active = await waitSession(page, {
|
|
||||||
directory: next.directory,
|
|
||||||
sessionID: next.sessionID,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
})
|
|
||||||
trackSession(next.sessionID, active.directory)
|
|
||||||
if (!input.shell) {
|
|
||||||
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
|
||||||
}
|
|
||||||
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
|
||||||
return next.sessionID
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = async (text: string) => {
|
|
||||||
return send(text, { noReply: false, shell: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = async (text: string) => {
|
|
||||||
return send(text, { noReply: true, shell: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const shell = async (cmd: string) => {
|
|
||||||
return send(cmd, { noReply: false, shell: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
const cur = state
|
|
||||||
if (!cur) return
|
|
||||||
setHealthPhase(page, "cleanup")
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(cur.sessions, ([sessionID, directory]) =>
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(cur.directory)
|
await cleanupTestProject(root)
|
||||||
state = undefined
|
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
return {
|
|
||||||
project: {
|
|
||||||
open,
|
|
||||||
prompt,
|
|
||||||
user,
|
|
||||||
shell,
|
|
||||||
gotoSession,
|
|
||||||
trackSession,
|
|
||||||
trackDirectory,
|
|
||||||
get directory() {
|
|
||||||
return need().directory
|
|
||||||
},
|
},
|
||||||
get slug() {
|
})
|
||||||
return need().slug
|
|
||||||
},
|
|
||||||
get sdk() {
|
|
||||||
return need().sdk
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cleanup,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedStorage(
|
|
||||||
page: Page,
|
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
serverUrl?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const origin = input.serverUrl ?? serverUrl
|
|
||||||
await page.addInitScript(
|
|
||||||
(args: {
|
|
||||||
directory: string
|
|
||||||
serverUrl: string
|
|
||||||
extra: string[]
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
}) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const next = { ...(projects as Record<string, unknown>) }
|
|
||||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
|
||||||
const current = next[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
if (existing.some((p) => p.worktree === directory)) return
|
|
||||||
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const directory of [args.directory, ...args.extra]) {
|
|
||||||
add("local", directory)
|
|
||||||
add(args.serverUrl, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
|
||||||
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
|
||||||
|
|
||||||
|
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
|
await seedProjects(page, input)
|
||||||
|
await page.addInitScript(() => {
|
||||||
const win = window as E2EWindow
|
const win = window as E2EWindow
|
||||||
win.__opencode_e2e = {
|
win.__opencode_e2e = {
|
||||||
...win.__opencode_e2e,
|
...win.__opencode_e2e,
|
||||||
model: { enabled: true },
|
model: {
|
||||||
prompt: { enabled: true },
|
enabled: true,
|
||||||
terminal: { enabled: true, terminals: {} },
|
|
||||||
}
|
|
||||||
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
|
||||||
},
|
},
|
||||||
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
prompt: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
enabled: true,
|
||||||
|
terminals: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:model",
|
||||||
|
JSON.stringify({
|
||||||
|
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||||
|
user: [],
|
||||||
|
variant: {},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
|
|||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { clickListItem } from "../actions"
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, project.slug)
|
const menu = await openProjectMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
@@ -31,19 +31,13 @@ test("dialog edit project updates name and startup script", async ({ page, proje
|
|||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
await expect
|
const header = page.locator(".group\\/project").first()
|
||||||
.poll(
|
await expect(header).toContainText(name)
|
||||||
async () => {
|
|
||||||
await page.reload()
|
|
||||||
await openSidebar(page)
|
|
||||||
const reopened = await open()
|
const reopened = await open()
|
||||||
const value = await reopened.getByLabel("Name").inputValue()
|
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||||
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
await expect(reopened).toHaveCount(0)
|
await expect(reopened).toHaveCount(0)
|
||||||
return `${value}\n${next}`
|
})
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(`${name}\n${startup}`)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
|||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, project }) => {
|
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [other] })
|
await withProject(
|
||||||
|
async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
@@ -20,13 +21,14 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
() => {
|
||||||
const pathname = new URL(page.url()).pathname
|
const pathname = new URL(page.url()).pathname
|
||||||
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
if (pathname === "/") return "home"
|
if (pathname === "/") return "home"
|
||||||
return ""
|
return ""
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,9 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toBe(0)
|
.toBe(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,27 @@ import {
|
|||||||
createTestProject,
|
createTestProject,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
|
sessionIDFromUrl,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitSession,
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, project }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [other] })
|
await withProject(
|
||||||
|
async ({ directory }) => {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const currentSlug = dirSlug(project.directory)
|
const currentSlug = dirSlug(directory)
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
@@ -34,31 +37,35 @@ test("can switch between projects from sidebar", async ({ page, project }) => {
|
|||||||
await currentButton.click()
|
await currentButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [other] })
|
await withProject(
|
||||||
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await setWorkspacesEnabled(page, project.slug, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [project.slug])
|
const raw = await waitSlug(page, [slug])
|
||||||
const dir = base64Decode(raw)
|
const dir = base64Decode(raw)
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const space = await resolveDirectory(dir)
|
const space = await resolveDirectory(dir)
|
||||||
const next = dirSlug(space)
|
const next = dirSlug(space)
|
||||||
project.trackDirectory(space)
|
trackDirectory(space)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
@@ -71,7 +78,19 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
await waitSession(page, { directory: space })
|
await waitSession(page, { directory: space })
|
||||||
|
|
||||||
const created = await project.user("test")
|
// Create a session by sending a prompt
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.fill("test")
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
// Wait for the URL to update with the new session ID
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const created = sessionIDFromUrl(page.url())
|
||||||
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
|
trackSession(created, space)
|
||||||
|
await waitSessionSaved(space, created)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -82,12 +101,15 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await otherButton.click({ force: true })
|
await otherButton.click({ force: true })
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click({ force: true })
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitDir,
|
waitDir,
|
||||||
waitSession,
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
function item(space: { slug: string; raw: string }) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
@@ -48,31 +50,45 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
project: Parameters<typeof test>[0]["project"],
|
|
||||||
page: Page,
|
page: Page,
|
||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
await openWorkspaceNewSession(page, space)
|
await openWorkspaceNewSession(page, space)
|
||||||
return project.user(text)
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.fill(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
|
||||||
|
await waitSessionSaved(space.directory, sessionID)
|
||||||
|
await createSdk(space.directory)
|
||||||
|
.session.abort({ sessionID })
|
||||||
|
.catch(() => undefined)
|
||||||
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, project.slug, true)
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, project.slug, [])
|
const first = await createWorkspace(page, root, [])
|
||||||
project.trackDirectory(first.directory)
|
trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, project.slug, [first.slug])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
project.trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||||
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||||
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
waitDir,
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
|
|||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
project.trackDirectory(next.directory)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -53,37 +52,37 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
|
|||||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await project.open()
|
|
||||||
|
|
||||||
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, project.slug, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, project.slug, false)
|
await setWorkspacesEnabled(page, slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, project }) => {
|
test("can create a workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await project.open()
|
|
||||||
|
|
||||||
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, project.slug, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
project.trackDirectory(next.directory)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -103,9 +102,12 @@ test("can create a workspace", async ({ page, project }) => {
|
|||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
|
await cleanupTestProject(next.directory)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
@@ -114,7 +116,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, project })
|
|||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [nonGit] })
|
await withProject(async () => {
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
@@ -124,16 +126,34 @@ test("non-git projects keep workspace mode disabled", async ({ page, project })
|
|||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
|
||||||
|
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||||
|
const hasMenu = await trigger
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!hasMenu) return
|
||||||
|
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toBeDisabled()
|
||||||
|
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(nonGit)
|
await cleanupTestProject(nonGit)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, project }) => {
|
test("can rename a workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await project.open()
|
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
@@ -145,25 +165,17 @@ test("can rename a workspace", async ({ page, project }) => {
|
|||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
const input = item.locator(inlineInputSelector).first()
|
const input = item.locator(inlineInputSelector).first()
|
||||||
const shown = await input
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!shown) {
|
|
||||||
const retry = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
|
||||||
await expect(retry).toHaveCount(0)
|
|
||||||
}
|
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(rename)
|
await input.fill(rename)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
await expect(item).toContainText(rename)
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, project }) => {
|
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await project.open()
|
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
const readme = path.join(createdDir, "README.md")
|
const readme = path.join(createdDir, "README.md")
|
||||||
@@ -184,7 +196,7 @@ test("can reset a workspace", async ({ page, project }) => {
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const files = await project.sdk.file
|
const files = await sdk.file
|
||||||
.status({ directory: createdDir })
|
.status({ directory: createdDir })
|
||||||
.then((r) => r.data ?? [])
|
.then((r) => r.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -199,17 +211,17 @@ test("can reset a workspace", async ({ page, project }) => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const files = await project.sdk.file
|
const files = await sdk.file
|
||||||
.status({ directory: createdDir })
|
.status({ directory: createdDir })
|
||||||
.then((r) => r.data ?? [])
|
.then((r) => r.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
return files.length
|
return files.length
|
||||||
},
|
},
|
||||||
{ timeout: 120_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(0)
|
.toBe(0)
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -219,12 +231,60 @@ test("can reset a workspace", async ({ page, project }) => {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
})
|
})
|
||||||
.toBe(false)
|
.toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
test("can delete a workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await project.open()
|
|
||||||
const rootSlug = project.slug
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await withProject(async ({ slug: rootSlug }) => {
|
||||||
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
|
|
||||||
const listSlugs = async () => {
|
const listSlugs = async () => {
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
@@ -265,17 +325,16 @@ test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
|||||||
await page.mouse.up()
|
await page.mouse.up()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
project.trackDirectory(next.directory)
|
|
||||||
workspaces.push(next)
|
workspaces.push(next)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -309,60 +368,8 @@ test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
|||||||
await drag(from, to)
|
await drag(from, to)
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
})
|
} finally {
|
||||||
|
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||||
test("can delete a workspace", async ({ page, project }) => {
|
}
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
})
|
||||||
await project.open()
|
|
||||||
|
|
||||||
const rootSlug = project.slug
|
|
||||||
await openSidebar(page)
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
|
||||||
|
|
||||||
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
|
||||||
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
|
||||||
|
|
||||||
const directory = created.directory
|
|
||||||
const slug = dirSlug(directory)
|
|
||||||
project.trackDirectory(directory)
|
|
||||||
|
|
||||||
await page.reload()
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await project.sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await project.sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
type Hit = { body: Record<string, unknown> }
|
|
||||||
|
|
||||||
export function bodyText(hit: Hit) {
|
|
||||||
return JSON.stringify(hit.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match requests whose body contains the exact serialized tool input.
|
|
||||||
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
|
||||||
* gets escaped again inside the JSON body — so we double-escape to match.
|
|
||||||
*/
|
|
||||||
export function inputMatch(input: unknown) {
|
|
||||||
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(escaped)
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,47 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { assistantText, withSession } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
await gotoSession()
|
||||||
await project.open()
|
|
||||||
await assistant.reply(token)
|
|
||||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
|
||||||
|
|
||||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Agent response arrives via SSE despite sync endpoint being dead
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
} finally {
|
||||||
|
await cleanupSession({ sdk, sessionID })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
type Probe = {
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string; name?: string }
|
|
||||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
|
||||||
agents?: Array<{ name: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probe(page: Page): Promise<Probe | null> {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
current?: Probe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return win.__opencode_e2e?.model?.current ?? null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function state(page: Page) {
|
|
||||||
const value = await probe(page)
|
|
||||||
if (!value) throw new Error("Failed to resolve model selection probe")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ready(page: Page) {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.pressSequentially("focus")
|
|
||||||
return prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
async function body(prompt: Locator) {
|
|
||||||
return prompt.evaluate((el) => (el as HTMLElement).innerText)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = await ready(page)
|
|
||||||
|
|
||||||
const info = await state(page)
|
|
||||||
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
|
|
||||||
test.skip(!next, "only one agent available")
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
|
|
||||||
|
|
||||||
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click({ force: true })
|
|
||||||
|
|
||||||
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
|
|
||||||
next,
|
|
||||||
)
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.pressSequentially(" agent")
|
|
||||||
await expect.poll(() => body(prompt)).toContain("focus agent")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = await ready(page)
|
|
||||||
|
|
||||||
const info = await state(page)
|
|
||||||
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
|
|
||||||
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
|
|
||||||
test.skip(!next, "only one model available")
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
|
|
||||||
|
|
||||||
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click({ force: true })
|
|
||||||
|
|
||||||
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.pressSequentially(" model")
|
|
||||||
await expect.poll(() => body(prompt)).toContain("focus model")
|
|
||||||
})
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { assistantText } from "../actions"
|
import { withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
type Sdk = ReturnType<typeof createSdk>
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -15,15 +13,54 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function edge(page: Page, pos: "start" | "end") {
|
||||||
|
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes: Text[] = []
|
||||||
|
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||||
|
nodes.push(node as Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
const node = document.createTextNode("")
|
||||||
|
el.appendChild(node)
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}, pos)
|
||||||
|
}
|
||||||
|
|
||||||
async function wait(page: Page, value: string) {
|
async function wait(page: Page, value: string) {
|
||||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||||
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -42,31 +79,36 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
const draft = `draft ${Date.now()}`
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
await project.open()
|
await prompt.click()
|
||||||
await assistant.reply(firstToken)
|
await page.keyboard.type(first)
|
||||||
const sessionID = await project.prompt(first)
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await reply(project.sdk, sessionID, firstToken)
|
await reply(sdk, session.id, firstToken)
|
||||||
|
|
||||||
await assistant.reply(secondToken)
|
await prompt.click()
|
||||||
await project.prompt(second)
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await reply(project.sdk, sessionID, secondToken)
|
await reply(sdk, session.id, secondToken)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(draft)
|
await page.keyboard.type(draft)
|
||||||
await wait(page, draft)
|
await wait(page, draft)
|
||||||
|
|
||||||
|
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||||
await prompt.fill("")
|
await prompt.fill("")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
|
||||||
@@ -81,11 +123,16 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
@@ -93,29 +140,19 @@ test.fixme("shell history stays separate from normal prompt history", async ({ p
|
|||||||
const second = `echo ${secondToken}`
|
const second = `echo ${secondToken}`
|
||||||
const normal = `Reply with exactly: ${normalToken}`
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
await page.keyboard.type(first)
|
await page.keyboard.type(first)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, first, firstToken)
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
await shell(sdk, sessionID, first, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
await page.keyboard.type(second)
|
await page.keyboard.type(second)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await shell(sdk, sessionID, second, secondToken)
|
await shell(sdk, session.id, second, secondToken)
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
@@ -138,9 +175,10 @@ test.fixme("shell history stays separate from normal prompt history", async ({ p
|
|||||||
await page.keyboard.type(normal)
|
await page.keyboard.type(normal)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await reply(sdk, sessionID, normalToken)
|
await reply(sdk, session.id, normalToken)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, normal)
|
await wait(page, normal)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { closeDialog, openSettings, withSession } from "../actions"
|
import { sessionIDFromUrl } from "../actions"
|
||||||
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -10,34 +11,34 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
const sdk = createSdk(directory)
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||||
|
|
||||||
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
await gotoSession()
|
||||||
project.trackSession(session.id)
|
await prompt.click()
|
||||||
await project.gotoSession(session.id)
|
await page.keyboard.type("!")
|
||||||
const dialog = await openSettings(page)
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
|
||||||
const input = toggle.locator('[data-slot="switch-input"]').first()
|
await page.keyboard.type(cmd)
|
||||||
await expect(toggle).toBeVisible()
|
await page.keyboard.press("Enter")
|
||||||
if ((await input.getAttribute("aria-checked")) !== "true") {
|
|
||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
|
||||||
}
|
const id = sessionIDFromUrl(page.url())
|
||||||
await closeDialog(page, dialog)
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await project.shell(cmd)
|
trackSession(id, directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const list = await project.sdk.session
|
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||||
.messages({ sessionID: session.id, limit: 50 })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
const msg = list.findLast(
|
const msg = list.findLast(
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||||
)
|
)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
@@ -50,25 +51,12 @@ test("shell mode runs a command in the project directory", async ({ page, projec
|
|||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
if (!output.includes("README.md")) return
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
return { cwd: project.directory, output }
|
return { cwd: directory, output }
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
|
await expect(prompt).toHaveText("")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
|
|
||||||
await project.open()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector).first()
|
|
||||||
await expect(page.locator(promptModelSelector)).toHaveCount(1)
|
|
||||||
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
|
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
|
||||||
await expect(page.locator(promptModelSelector)).toHaveCount(0)
|
|
||||||
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -22,16 +22,14 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, project }) => {
|
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await project.open()
|
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
await seed(project.sdk, session.id)
|
await seed(sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/share")
|
await page.keyboard.type("/share")
|
||||||
@@ -41,7 +39,7 @@ test("/share and /unshare update session share state", async ({ page, project })
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -56,7 +54,7 @@ test("/share and /unshare update session share state", async ({ page, project })
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { assistantText } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
@@ -10,16 +11,42 @@ test("can send a prompt and receive a reply", async ({ page, project, assistant
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
try {
|
await gotoSession()
|
||||||
const token = `E2E_OK_${Date.now()}`
|
|
||||||
await project.open()
|
|
||||||
await assistant.reply(token)
|
|
||||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
|
||||||
|
|
||||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
const token = `E2E_OK_${Date.now()}`
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
|
||||||
|
const sessionID = (() => {
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
return id
|
||||||
|
})()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
|
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
||||||
|
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
||||||
|
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
||||||
|
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
||||||
|
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
|
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
@@ -13,8 +19,7 @@ export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
|||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||||
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||||
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
|
||||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
@@ -24,7 +29,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
|||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
@@ -34,6 +39,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectClearNotificationsSelector = (slug: string) =>
|
||||||
|
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
@@ -41,6 +49,8 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
|||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
|
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { seedSessionTask, withSession } from "../actions"
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { inputMatch } from "../prompt/mock"
|
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
@@ -11,54 +10,28 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
try {
|
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
await project.open()
|
const child = await seedSessionTask(sdk, {
|
||||||
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
sessionID: session.id,
|
||||||
const taskInput = {
|
|
||||||
description: "Open child session",
|
description: "Open child session",
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
subagent_type: "general",
|
|
||||||
}
|
|
||||||
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
|
||||||
const child = await seedSessionTask(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
description: taskInput.description,
|
|
||||||
prompt: taskInput.prompt,
|
|
||||||
})
|
})
|
||||||
project.trackSession(child.sessionID)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
try {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const header = page.locator("[data-session-title]")
|
const link = page
|
||||||
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
|
.locator("a.subagent-link")
|
||||||
|
|
||||||
const card = page
|
|
||||||
.locator('[data-component="task-tool-card"]')
|
|
||||||
.filter({ hasText: /open child session/i })
|
.filter({ hasText: /open child session/i })
|
||||||
.first()
|
.first()
|
||||||
await expect(card).toBeVisible({ timeout: 30_000 })
|
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||||
await card.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
|
await page.waitForTimeout(1000)
|
||||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
|
expect(errs).toEqual([])
|
||||||
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() =>
|
|
||||||
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
|
|
||||||
left: getComputedStyle(el).paddingLeft,
|
|
||||||
right: getComputedStyle(el).paddingRight,
|
|
||||||
})),
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ left: "8px", right: "8px" })
|
|
||||||
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
|
|
||||||
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onError)
|
page.off("pageerror", onError)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type ComposerProbeState,
|
type ComposerProbeState,
|
||||||
type ComposerWindow,
|
type ComposerWindow,
|
||||||
} from "../../src/testing/session-composer"
|
} from "../../src/testing/session-composer"
|
||||||
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions"
|
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
import { modKey } from "../utils"
|
|
||||||
import { inputMatch } from "../prompt/mock"
|
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||||
@@ -23,13 +21,12 @@ async function withDockSession<T>(
|
|||||||
sdk: Sdk,
|
sdk: Sdk,
|
||||||
title: string,
|
title: string,
|
||||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||||
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
opts?: { permission?: PermissionRule[] },
|
||||||
) {
|
) {
|
||||||
const session = await sdk.session
|
const session = await sdk.session
|
||||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
if (!session?.id) throw new Error("Session create did not return an id")
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
opts?.trackSession?.(session.id)
|
|
||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,17 +34,6 @@ async function withDockSession<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultQuestions = [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
@@ -65,14 +51,12 @@ async function clearPermissionDock(page: any, label: RegExp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
const dialog = await openSettings(page)
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
await expect(button).toBeVisible()
|
||||||
const input = toggle.locator('[data-slot="switch-input"]').first()
|
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
||||||
await expect(toggle).toBeVisible()
|
if (pressed === enabled) return
|
||||||
const checked = (await input.getAttribute("aria-checked")) === "true"
|
await button.click()
|
||||||
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click()
|
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||||
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectQuestionBlocked(page: any) {
|
async function expectQuestionBlocked(page: any) {
|
||||||
@@ -109,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
|
|||||||
|
|
||||||
const write = async (driver: ComposerDriverState | undefined) => {
|
const write = async (driver: ComposerDriverState | undefined) => {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
(input) => {
|
||||||
const win = window as ComposerWindow
|
const win = window as ComposerWindow
|
||||||
const composer = win.__opencode_e2e?.composer
|
const composer = win.__opencode_e2e?.composer
|
||||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||||
@@ -134,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const read = () =>
|
const read = () =>
|
||||||
page.evaluate((sessionID: string) => {
|
page.evaluate((sessionID) => {
|
||||||
const win = window as ComposerWindow
|
const win = window as ComposerWindow
|
||||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||||
}, sessionID) as Promise<ComposerProbeState | null>
|
}, sessionID) as Promise<ComposerProbeState | null>
|
||||||
@@ -202,8 +186,6 @@ async function withMockPermission<T>(
|
|||||||
opts: { child?: any } | undefined,
|
opts: { child?: any } | undefined,
|
||||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
const listUrl = /\/permission(?:\?.*)?$/
|
|
||||||
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
|
||||||
let pending = [
|
let pending = [
|
||||||
{
|
{
|
||||||
...request,
|
...request,
|
||||||
@@ -222,8 +204,7 @@ async function withMockPermission<T>(
|
|||||||
|
|
||||||
const reply = async (route: any) => {
|
const reply = async (route: any) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const parts = url.pathname.split("/").filter(Boolean)
|
const id = url.pathname.split("/").pop()
|
||||||
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
|
||||||
pending = pending.filter((item) => item.id !== id)
|
pending = pending.filter((item) => item.id !== id)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -232,10 +213,8 @@ async function withMockPermission<T>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.route(listUrl, list)
|
await page.route("**/permission", list)
|
||||||
for (const item of replyUrls) {
|
await page.route("**/session/*/permissions/*", reply)
|
||||||
await page.route(item, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionList = opts?.child
|
const sessionList = opts?.child
|
||||||
? async (route: any) => {
|
? async (route: any) => {
|
||||||
@@ -244,7 +223,9 @@ async function withMockPermission<T>(
|
|||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
response: res,
|
status: res.status(),
|
||||||
|
headers: res.headers(),
|
||||||
|
contentType: "application/json",
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -261,55 +242,54 @@ async function withMockPermission<T>(
|
|||||||
try {
|
try {
|
||||||
return await fn(state)
|
return await fn(state)
|
||||||
} finally {
|
} finally {
|
||||||
await page.unroute(listUrl, list)
|
await page.unroute("**/permission", list)
|
||||||
for (const item of replyUrls) {
|
await page.unroute("**/session/*/permissions/*", reply)
|
||||||
await page.unroute(item, reply)
|
|
||||||
}
|
|
||||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, project }) => {
|
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||||
await withDockSession(
|
await gotoSession(session.id)
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock default",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
|
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||||
await project.open()
|
await gotoSession()
|
||||||
|
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
await setAutoAccept(page, true)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
await withDockSession(
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
project.sdk,
|
await gotoSession(session.id)
|
||||||
"e2e composer dock question",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
await seedSessionQuestion(sdk, {
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
questions: defaultQuestions,
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
@@ -320,81 +300,12 @@ test("blocked question flow unblocks after submit", async ({ page, llm, project
|
|||||||
|
|
||||||
await expectQuestionOpen(page)
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
await withDockSession(
|
await gotoSession(session.id)
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question keyboard",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await expect(second).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Space")
|
|
||||||
await page.keyboard.press(`${modKey}+Enter`)
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
|
||||||
await project.open()
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question escape",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
|
||||||
await project.open()
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission once",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
@@ -416,18 +327,12 @@ test("blocked permission flow supports allow once", async ({ page, project }) =>
|
|||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, project }) => {
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
await withDockSession(
|
await gotoSession(session.id)
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission reject",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
@@ -448,18 +353,12 @@ test("blocked permission flow supports reject", async ({ page, project }) => {
|
|||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
await withDockSession(
|
await gotoSession(session.id)
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission always",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
@@ -481,13 +380,30 @@ test("blocked permission flow supports allow always", async ({ page, project })
|
|||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||||
const questions = [
|
page,
|
||||||
|
sdk,
|
||||||
|
gotoSession,
|
||||||
|
}) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const child = await sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child question",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDockSeed(sdk, child.id, async () => {
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: child.id,
|
||||||
|
questions: [
|
||||||
{
|
{
|
||||||
header: "Child input",
|
header: "Child input",
|
||||||
question: "Pick one child option",
|
question: "Pick one child option",
|
||||||
@@ -496,29 +412,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
{ label: "Stop", description: "Stop child" },
|
{ label: "Stop", description: "Stop child" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
await project.open()
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock child question parent",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child question",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
project.trackSession(child.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withDockSeed(project.sdk, child.id, async () => {
|
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
@@ -530,30 +424,27 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
await expectQuestionOpen(page)
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
|
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||||
await project.open()
|
page,
|
||||||
await withDockSession(
|
sdk,
|
||||||
project.sdk,
|
gotoSession,
|
||||||
"e2e composer dock child permission parent",
|
}) => {
|
||||||
async (session) => {
|
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
|
|
||||||
const child = await project.sdk.session
|
const child = await sdk.session
|
||||||
.create({
|
.create({
|
||||||
title: "e2e composer dock child permission",
|
title: "e2e composer dock child permission",
|
||||||
parentID: session.id,
|
parentID: session.id,
|
||||||
})
|
})
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
project.trackSession(child.id)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
@@ -578,21 +469,15 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
await project.open()
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock todo",
|
|
||||||
async (session) => {
|
|
||||||
const dock = await todoDock(page, session.id)
|
const dock = await todoDock(page, session.id)
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -616,31 +501,23 @@ test("todo dock transitions and collapse behavior", async ({ page, project }) =>
|
|||||||
} finally {
|
} finally {
|
||||||
await dock.clear()
|
await dock.clear()
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||||
const questions = [
|
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
{
|
{
|
||||||
header: "Need input",
|
header: "Need input",
|
||||||
question: "Pick one option",
|
question: "Pick one option",
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
await project.open()
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock keyboard",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
await expectQuestionBlocked(page)
|
||||||
@@ -649,7 +526,5 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, projec
|
|||||||
await page.keyboard.type("abc")
|
await page.keyboard.type("abc")
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
},
|
})
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
|
promptSelector,
|
||||||
promptVariantSelector,
|
promptVariantSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceNewSessionSelector,
|
workspaceNewSessionSelector,
|
||||||
@@ -19,17 +28,7 @@ type Footer = {
|
|||||||
type Probe = {
|
type Probe = {
|
||||||
dir?: string
|
dir?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string; name?: string }
|
|
||||||
variant?: string | null
|
|
||||||
pick?: {
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string }
|
model?: { providerID: string; modelID: string }
|
||||||
variant?: string | null
|
|
||||||
}
|
|
||||||
variants?: string[]
|
|
||||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
|
||||||
agents?: Array<{ name: string }>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
@@ -51,86 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function currentModel(page: Page) {
|
|
||||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
|
||||||
const value = await probe(page).then(modelKey)
|
|
||||||
if (!value) throw new Error("Failed to resolve current model key")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() =>
|
|
||||||
page.evaluate((key) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !!win.__opencode_e2e?.model?.controls?.[key]
|
|
||||||
}, key),
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickAgent(page: Page, value: string) {
|
|
||||||
await waitControl(page, "setAgent")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setAgent?: (value: string | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
|
||||||
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
|
||||||
await waitControl(page, "setModel")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
|
||||||
if (!fn) throw new Error("Model e2e model control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickVariant(page: Page, value: string) {
|
|
||||||
await waitControl(page, "setVariant")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setVariant?: (value: string | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
|
||||||
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function read(page: Page): Promise<Footer> {
|
async function read(page: Page): Promise<Footer> {
|
||||||
return {
|
return {
|
||||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
@@ -163,15 +82,31 @@ async function waitModel(page: Page, value: string) {
|
|||||||
async function choose(page: Page, root: string, value: string) {
|
async function choose(page: Page, root: string, value: string) {
|
||||||
const select = page.locator(root)
|
const select = page.locator(root)
|
||||||
await expect(select).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
await pickAgent(page, value)
|
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||||
|
const item = page
|
||||||
|
.locator('[data-slot="select-select-item"]')
|
||||||
|
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
|
||||||
|
.first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function variantCount(page: Page) {
|
async function variantCount(page: Page) {
|
||||||
return (await probe(page))?.variants?.length ?? 0
|
const select = page.locator(promptVariantSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
const count = await page.locator('[data-slot="select-select-item"]').count()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
async function agents(page: Page) {
|
async function agents(page: Page) {
|
||||||
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
const select = page.locator(promptAgentSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||||
|
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
return labels.map((item) => item.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||||
@@ -197,23 +132,48 @@ async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
|||||||
|
|
||||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||||
const current = await read(page)
|
const current = await read(page)
|
||||||
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
const select = page.locator(promptVariantSelector)
|
||||||
if (!next) throw new Error("Current model has no alternate variant to select")
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
await pickVariant(page, next)
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
const count = await items.count()
|
||||||
|
if (count < 2) throw new Error("Current model has no alternate variant to select")
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const item = items.nth(i)
|
||||||
|
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
|
||||||
|
if (!next || next === current.variant) continue
|
||||||
|
await item.click()
|
||||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to choose a different variant")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||||
const current = await currentModel(page)
|
const current = await read(page)
|
||||||
const next = (await probe(page))?.models?.find((item) => {
|
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
|
||||||
const key = `${item.providerID}:${item.modelID}`
|
await expect(button).toBeVisible()
|
||||||
return key !== current && !skip.includes(key)
|
await button.click()
|
||||||
})
|
|
||||||
if (!next) throw new Error("Failed to choose a different model")
|
const dialog = page.getByRole("dialog")
|
||||||
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
await expect(dialog).toBeVisible()
|
||||||
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
const items = dialog.locator('[data-slot="list-item"]')
|
||||||
|
const count = await items.count()
|
||||||
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const item = items.nth(i)
|
||||||
|
const selected = (await item.getAttribute("data-selected")) === "true"
|
||||||
|
if (selected) continue
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
|
||||||
return read(page)
|
return read(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to choose a different model")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
@@ -221,8 +181,32 @@ async function goto(page: Page, directory: string, sessionID?: string) {
|
|||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
async function submit(page: Page, value: string) {
|
||||||
return project.prompt(value)
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
await prompt.fill(value)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUser(directory: string, sessionID: string) {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
@@ -265,67 +249,78 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||||
|
page,
|
||||||
|
withProject,
|
||||||
|
}) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
await ensureVariant(page, directory)
|
||||||
const firstKey = await currentModel(page)
|
const firstState = await chooseDifferentVariant(page)
|
||||||
const first = await submit(project, `session variant ${Date.now()}`)
|
const first = await submit(page, `session variant ${Date.now()}`)
|
||||||
|
trackSession(first)
|
||||||
|
await waitUser(directory, first)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitSession(page, { directory: project.directory, sessionID: first })
|
await waitSession(page, { directory, sessionID: first })
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
const fresh = await read(page)
|
const fresh = await ensureVariant(page, directory)
|
||||||
expect(fresh.model).not.toBe(firstState.model)
|
expect(fresh.variant).not.toBe(firstState.variant)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page)
|
||||||
const second = await submit(project, `session model ${Date.now()}`)
|
const second = await submit(page, `session model ${Date.now()}`)
|
||||||
|
trackSession(second)
|
||||||
|
await waitUser(directory, second)
|
||||||
|
|
||||||
await goto(page, project.directory, first)
|
await goto(page, directory, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, project.directory, second)
|
await goto(page, directory, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
await page.reload()
|
|
||||||
await waitSession(page, { directory: project.directory })
|
|
||||||
await waitFooter(page, fresh)
|
await waitFooter(page, fresh)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session model restore across workspaces", async ({ page, project }) => {
|
test("session model restore across workspaces", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||||
const root = project.directory
|
await gotoSession()
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
await ensureVariant(page, root)
|
||||||
const firstKey = await currentModel(page)
|
const firstState = await chooseDifferentVariant(page)
|
||||||
const first = await submit(project, `root session ${Date.now()}`)
|
const first = await submit(page, `root session ${Date.now()}`)
|
||||||
|
trackSession(first, root)
|
||||||
|
await waitUser(root, first)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, project.slug, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
const one = await createWorkspace(page, project.slug, [])
|
const one = await createWorkspace(page, slug, [])
|
||||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
project.trackDirectory(oneDir)
|
trackDirectory(oneDir)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page)
|
||||||
const secondKey = await currentModel(page)
|
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||||
const second = await submit(project, `workspace one ${Date.now()}`)
|
trackSession(second, oneDir)
|
||||||
|
await waitUser(oneDir, second)
|
||||||
|
|
||||||
const two = await createWorkspace(page, project.slug, [one.slug])
|
const two = await createWorkspace(page, slug, [one.slug])
|
||||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
project.trackDirectory(twoDir)
|
trackDirectory(twoDir)
|
||||||
|
|
||||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
await ensureVariant(page, twoDir)
|
||||||
const third = await submit(project, `workspace two ${Date.now()}`)
|
const thirdState = await chooseDifferentVariant(page)
|
||||||
|
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||||
|
trackSession(third, twoDir)
|
||||||
|
await waitUser(twoDir, third)
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
@@ -338,15 +333,16 @@ test("session model restore across workspaces", async ({ page, project }) => {
|
|||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await ensureVariant(page, project.directory)
|
await ensureVariant(page, directory)
|
||||||
const updated = await chooseDifferentVariant(page)
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
const available = await agents(page)
|
const available = await agents(page)
|
||||||
@@ -359,4 +355,5 @@ test("variant preserved when switching agent modes", async ({ page, project }) =
|
|||||||
|
|
||||||
await choose(page, promptAgentSelector, updated.agent)
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { waitSessionIdle, withSession } from "../actions"
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { bodyText } from "../prompt/mock"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
const count = 14
|
const count = 14
|
||||||
|
|
||||||
@@ -40,19 +40,8 @@ function edit(file: string, prev: string, next: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchWithMock(
|
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||||
llm: Parameters<typeof test>[0]["llm"],
|
await sdk.session.promptAsync({
|
||||||
sdk: Parameters<typeof withSession>[0],
|
|
||||||
sessionID: string,
|
|
||||||
patchText: string,
|
|
||||||
) {
|
|
||||||
const callsBefore = await llm.calls()
|
|
||||||
await llm.toolMatch(
|
|
||||||
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
|
|
||||||
"apply_patch",
|
|
||||||
{ patchText },
|
|
||||||
)
|
|
||||||
await sdk.session.prompt({
|
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
system: [
|
system: [
|
||||||
@@ -65,16 +54,7 @@ async function patchWithMock(
|
|||||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 120_000 },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
@@ -253,7 +233,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
const tag = `review-comment-${Date.now()}`
|
||||||
@@ -262,15 +242,16 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
const sdk = createSdk(project.directory)
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
@@ -298,9 +279,10 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
@@ -309,15 +291,16 @@ test("review file comments submit on click without clipping actions", async ({ p
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
const sdk = createSdk(project.directory)
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
@@ -346,9 +329,11 @@ test("review file comments submit on click without clipping actions", async ({ p
|
|||||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
|
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-${Date.now()}`
|
const tag = `review-${Date.now()}`
|
||||||
@@ -358,15 +343,16 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
const sdk = createSdk(project.directory)
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed(list))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
return info?.summary?.files ?? 0
|
return info?.summary?.files ?? 0
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
@@ -376,7 +362,7 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return diff.length
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
@@ -393,16 +379,15 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
await expect(view).toBeVisible()
|
await expect(view).toBeVisible()
|
||||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
await expect(heads).toHaveCount(list.length, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expand(page)
|
await expand(page)
|
||||||
await waitMark(page, hit.file, hit.mark)
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
const row = page
|
const row = page
|
||||||
.getByRole("heading", {
|
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||||
level: 3,
|
|
||||||
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
||||||
})
|
|
||||||
.first()
|
.first()
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
@@ -411,12 +396,12 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
const prev = await spot(page, hit.file)
|
const prev = await spot(page, hit.file)
|
||||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
const item = diff.find((item) => item.file === hit.file)
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
return typeof item?.after === "string" ? item.after : ""
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
},
|
},
|
||||||
@@ -437,4 +422,5 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
)
|
)
|
||||||
.toBeLessThanOrEqual(32)
|
.toBeLessThanOrEqual(32)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,16 +49,15 @@ async function seedConversation(input: {
|
|||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
@@ -79,18 +78,18 @@ test("slash undo sets revert and restores prior prompt", async ({ page, project
|
|||||||
await expect(seeded.prompt).toContainText(token)
|
await expect(seeded.prompt).toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
@@ -126,19 +125,19 @@ test("slash redo clears revert and restores latest state", async ({ page, projec
|
|||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await project.open()
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const first = await seedConversation({
|
const first = await seedConversation({
|
||||||
@@ -230,4 +229,5 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, project
|
|||||||
await expect(firstMessage).toHaveCount(1)
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage).toHaveCount(1)
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,16 +31,14 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, project }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await project.open()
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await withSession(project.sdk, originalTitle, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
project.trackSession(session.id)
|
await gotoSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
@@ -56,7 +54,7 @@ test("session can be renamed via header menu", async ({ page, project }) => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.title
|
return data?.title
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -67,22 +65,20 @@ test("session can be renamed via header menu", async ({ page, project }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, project }) => {
|
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await project.open()
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
project.trackSession(session.id)
|
await gotoSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
await clickMenuItem(menu, /archive/i)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.time?.archived
|
return data?.time?.archived
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -94,15 +90,13 @@ test("session can be archived via header menu", async ({ page, project }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, project }) => {
|
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await project.open()
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
project.trackSession(session.id)
|
await gotoSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /delete/i)
|
await confirmDialog(page, /delete/i)
|
||||||
@@ -110,7 +104,7 @@ test("session can be deleted via header menu", async ({ page, project }) => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session
|
const data = await sdk.session
|
||||||
.get({ sessionID: session.id })
|
.get({ sessionID: session.id })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -125,17 +119,15 @@ test("session can be deleted via header menu", async ({ page, project }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await project.open()
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
project.trackSession(session.id)
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await project.prompt(`share seed ${stamp}`)
|
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
@@ -149,7 +141,7 @@ test("session can be shared and unshared via header button", async ({ page, proj
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -167,7 +159,7 @@ test("session can be shared and unshared via header button", async ({ page, proj
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
|
|||||||
import { closeDialog, openSettings } from "../actions"
|
import { closeDialog, openSettings } from "../actions"
|
||||||
import {
|
import {
|
||||||
settingsColorSchemeSelector,
|
settingsColorSchemeSelector,
|
||||||
settingsCodeFontSelector,
|
settingsFontSelector,
|
||||||
settingsLanguageSelectSelector,
|
settingsLanguageSelectSelector,
|
||||||
settingsNotificationsAgentSelector,
|
settingsNotificationsAgentSelector,
|
||||||
settingsNotificationsErrorsSelector,
|
settingsNotificationsErrorsSelector,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
settingsSoundsErrorsSelector,
|
settingsSoundsErrorsSelector,
|
||||||
settingsSoundsPermissionsSelector,
|
settingsSoundsPermissionsSelector,
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
settingsUIFontSelector,
|
|
||||||
settingsUpdatesStartupSelector,
|
settingsUpdatesStartupSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
|
||||||
@@ -88,20 +87,10 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
})
|
})
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
|
||||||
|
|
||||||
await trigger.click()
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
const open = await expect
|
|
||||||
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
.toBe(true)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!open) {
|
|
||||||
await trigger.click()
|
|
||||||
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
|
|
||||||
}
|
|
||||||
await expect(items.first()).toBeVisible()
|
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
@@ -163,199 +152,39 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
|||||||
.toBeNull()
|
.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
const input = dialog.locator(settingsCodeFontSelector)
|
const select = dialog.locator(settingsFontSelector)
|
||||||
await expect(input).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
const initialFontFamily = await page.evaluate(() => {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||||
)
|
})
|
||||||
const initialUIFamily = await page.evaluate(() =>
|
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(initialFontFamily).toContain("ui-monospace")
|
|
||||||
|
|
||||||
const next = "Test Mono"
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
await input.click()
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
await input.clear()
|
await items.nth(2).click()
|
||||||
await input.pressSequentially(next)
|
|
||||||
await expect(input).toHaveValue(next)
|
|
||||||
|
|
||||||
await expect
|
await page.waitForTimeout(100)
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
const stored = await page.evaluate((key) => {
|
||||||
const raw = localStorage.getItem(key)
|
const raw = localStorage.getItem(key)
|
||||||
return raw ? JSON.parse(raw) : null
|
return raw ? JSON.parse(raw) : null
|
||||||
}, settingsKey)
|
}, settingsKey)
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: next,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newFontFamily = await page.evaluate(() =>
|
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
const newFontFamily = await page.evaluate(() => {
|
||||||
const newUIFamily = await page.evaluate(() =>
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
})
|
||||||
)
|
|
||||||
expect(newFontFamily).toContain(next)
|
|
||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||||
expect(newUIFamily).toBe(initialUIFamily)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsUIFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
const initialCodeFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(initialFontFamily).toContain("ui-sans-serif")
|
|
||||||
|
|
||||||
const next = "Test Sans"
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially(next)
|
|
||||||
await expect(input).toHaveValue(next)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: next,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
const newCodeFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(newFontFamily).toContain(next)
|
|
||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
|
||||||
expect(newCodeFamily).toBe(initialCodeFamily)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsCodeFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially("Reset Mono")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: "Reset Mono",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await input.clear()
|
|
||||||
await input.press("Space")
|
|
||||||
await expect(input).toHaveValue("")
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(fontFamily).toContain("ui-monospace")
|
|
||||||
expect(fontFamily).not.toContain("Reset Mono")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsUIFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially("Reset Sans")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: "Reset Sans",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await input.clear()
|
|
||||||
await input.press("Space")
|
|
||||||
await expect(input).toHaveValue("")
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(fontFamily).toContain("ui-sans-serif")
|
|
||||||
expect(fontFamily).not.toContain("Reset Sans")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
@@ -366,35 +195,31 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||||
|
|
||||||
const code = dialog.locator(settingsCodeFontSelector)
|
const fontSelect = dialog.locator(settingsFontSelector)
|
||||||
const ui = dialog.locator(settingsUIFontSelector)
|
await expect(fontSelect).toBeVisible()
|
||||||
await expect(code).toBeVisible()
|
|
||||||
await expect(ui).toBeVisible()
|
|
||||||
|
|
||||||
const initialMono = await page.evaluate(() =>
|
const initialFontFamily = await page.evaluate(() => {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
)
|
})
|
||||||
const initialSans = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const initialSettings = await page.evaluate((key) => {
|
const initialSettings = await page.evaluate((key) => {
|
||||||
const raw = localStorage.getItem(key)
|
const raw = localStorage.getItem(key)
|
||||||
return raw ? JSON.parse(raw) : null
|
return raw ? JSON.parse(raw) : null
|
||||||
}, settingsKey)
|
}, settingsKey)
|
||||||
|
|
||||||
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
const currentFont =
|
||||||
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
await code.click()
|
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||||
await code.clear()
|
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||||
await code.pressSequentially(mono)
|
|
||||||
await expect(code).toHaveValue(mono)
|
|
||||||
|
|
||||||
await ui.click()
|
if (currentFont) {
|
||||||
await ui.clear()
|
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||||
await ui.pressSequentially(sans)
|
}
|
||||||
await expect(ui).toHaveValue(sans)
|
if (!currentFont) {
|
||||||
|
await fontItems.nth(1).click()
|
||||||
|
}
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -405,8 +230,7 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
mono,
|
font: expect.any(String),
|
||||||
sans,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,18 +239,11 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
return raw ? JSON.parse(raw) : null
|
return raw ? JSON.parse(raw) : null
|
||||||
}, settingsKey)
|
}, settingsKey)
|
||||||
|
|
||||||
const updatedMono = await page.evaluate(() =>
|
const updatedFontFamily = await page.evaluate(() => {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
)
|
})
|
||||||
const updatedSans = await page.evaluate(() =>
|
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||||
)
|
|
||||||
expect(updatedMono).toContain(mono)
|
|
||||||
expect(updatedMono).not.toBe(initialMono)
|
|
||||||
expect(updatedSans).toContain(sans)
|
|
||||||
expect(updatedSans).not.toBe(initialSans)
|
|
||||||
expect(updatedSettings?.appearance?.mono).toBe(mono)
|
|
||||||
expect(updatedSettings?.appearance?.sans).toBe(sans)
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
@@ -442,8 +259,7 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
mono,
|
font: updatedSettings?.appearance?.font,
|
||||||
sans,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,32 +270,17 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
return await page.evaluate(() =>
|
return await page.evaluate(() => {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.toContain(mono)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.toContain(sans)
|
.not.toBe(initialFontFamily)
|
||||||
|
|
||||||
const rehydratedMono = await page.evaluate(() =>
|
const rehydratedFontFamily = await page.evaluate(() => {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
)
|
})
|
||||||
const rehydratedSans = await page.evaluate(() =>
|
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||||
)
|
|
||||||
expect(rehydratedMono).toContain(mono)
|
|
||||||
expect(rehydratedMono).not.toBe(initialMono)
|
|
||||||
expect(rehydratedSans).toContain(sans)
|
|
||||||
expect(rehydratedSans).not.toBe(initialSans)
|
|
||||||
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
|
|
||||||
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
|||||||
@@ -48,51 +48,57 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [other] })
|
await withProject(
|
||||||
|
async () => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
await expect(projectButton).toBeVisible()
|
await expect(project).toBeVisible()
|
||||||
await projectButton.hover()
|
await project.hover()
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
await projectButton.click()
|
await page.mouse.down()
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
|
await page.mouse.up()
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await project.open({ extra: [other] })
|
await withProject(
|
||||||
|
async () => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
await expect(projectButton).toBeVisible()
|
await expect(project).toBeVisible()
|
||||||
|
|
||||||
let hit = false
|
let hit = false
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
hit = await projectButton.evaluate((el) => {
|
hit = await project.evaluate((el) => {
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
})
|
})
|
||||||
if (hit) break
|
if (hit) break
|
||||||
@@ -103,6 +109,9 @@ test("open sidebar project switch activates on first tabbed enter", async ({ pag
|
|||||||
|
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ async function open(page: Page) {
|
|||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
||||||
await project.open()
|
await withProject(async ({ gotoSession }) => {
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const term = await open(page)
|
const term = await open(page)
|
||||||
const id = await term.getAttribute("data-pty-id")
|
const id = await term.getAttribute("data-pty-id")
|
||||||
@@ -42,4 +42,5 @@ test("terminal reconnects without replacing the pty", async ({ page, project })
|
|||||||
token,
|
token,
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ async function store(page: Page, key: string) {
|
|||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
const key = workspacePersistKey(project.directory, "terminal")
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
@@ -89,14 +89,15 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
|||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: true, second: false })
|
.toEqual({ first: true, second: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
const key = workspacePersistKey(project.directory, "terminal")
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
@@ -127,15 +128,16 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||||
await project.open()
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
const key = workspacePersistKey(project.directory, "terminal")
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
const rename = `E2E term ${Date.now()}`
|
const rename = `E2E term ${Date.now()}`
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
await project.gotoSession()
|
await gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
@@ -162,4 +164,5 @@ test("terminal tab can be renamed from the context menu", async ({ page, project
|
|||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toBe(rename)
|
.toBe(rename)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
|||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
export function createSdk(directory?: string) {
|
||||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
export async function resolveDirectory(directory: string) {
|
||||||
return createSdk(directory, baseUrl)
|
return createSdk(directory)
|
||||||
.path.get()
|
.path.get()
|
||||||
.then((x) => x.data?.directory ?? directory)
|
.then((x) => x.data?.directory ?? directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktree(baseUrl = serverUrl) {
|
export async function getWorktree() {
|
||||||
const sdk = createSdk(undefined, baseUrl)
|
const sdk = createSdk()
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
const data = result.data
|
const data = result.data
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
||||||
return data.worktree
|
return data.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en" style="background-color: var(--background-base)">
|
<html lang="en" style="background-color: var(--background-base)">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>OpenCode</title>
|
<title>OpenCode</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.3",
|
"version": "1.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit",
|
||||||
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
@@ -47,10 +46,9 @@
|
|||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.5",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
"@solid-primitives/timer": "1.4.4",
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
|
||||||
|
|
||||||
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
|
||||||
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
@@ -24,7 +19,7 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers,
|
workers,
|
||||||
reporter,
|
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const serverEnv = {
|
|||||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
@@ -87,7 +87,7 @@ const runnerEnv = {
|
|||||||
|
|
||||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
let seed: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
let runner: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined
|
let server: { stop: () => Promise<void> | void } | undefined
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||||
let cleaned = false
|
let cleaned = false
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ const cleanup = async () => {
|
|||||||
|
|
||||||
const jobs = [
|
const jobs = [
|
||||||
inst?.Instance.disposeAll(),
|
inst?.Instance.disposeAll(),
|
||||||
typeof server?.stop === "function" ? server.stop() : undefined,
|
server?.stop(),
|
||||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
await Promise.allSettled(jobs)
|
await Promise.allSettled(jobs)
|
||||||
@@ -158,7 +158,7 @@ try {
|
|||||||
|
|
||||||
const servermod = await import("../../opencode/src/server/server")
|
const servermod = await import("../../opencode/src/server/server")
|
||||||
inst = await import("../../opencode/src/project/instance")
|
inst = await import("../../opencode/src/project/instance")
|
||||||
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||||
|
|
||||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||||
|
|||||||
+12
-14
@@ -37,6 +37,7 @@ import { LayoutProvider } from "@/context/layout"
|
|||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
@@ -47,14 +48,9 @@ import { ErrorPage } from "./pages/error"
|
|||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const loadSession = () => import("@/pages/session")
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Session = lazy(loadSession)
|
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
|
||||||
void loadSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Session />
|
<Session />
|
||||||
@@ -81,6 +77,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
|
}
|
||||||
|
|
||||||
function QueryProvider(props: ParentProps) {
|
function QueryProvider(props: ParentProps) {
|
||||||
const client = new QueryClient()
|
const client = new QueryClient()
|
||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
@@ -143,9 +144,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</MarkedProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -182,7 +183,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
if (checkMode() === "background" || type === "http") return false
|
if (checkMode() === "background" || type === "http") return false
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||||
|
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
),
|
),
|
||||||
@@ -282,11 +284,7 @@ export function AppInterface(props: {
|
|||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
defaultServer={props.defaultServer}
|
|
||||||
disableHealthCheck={props.disableHealthCheck}
|
|
||||||
servers={props.servers}
|
|
||||||
>
|
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -350,12 +349,13 @@ export function DebugBar() {
|
|||||||
|
|
||||||
syncHeap()
|
syncHeap()
|
||||||
start()
|
start()
|
||||||
makeEventListener(document, "visibilitychange", vis)
|
document.addEventListener("visibilitychange", vis)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (one !== 0) cancelAnimationFrame(one)
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
if (two !== 0) cancelAnimationFrame(two)
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
stop()
|
stop()
|
||||||
|
document.removeEventListener("visibilitychange", vis)
|
||||||
for (const ob of obs) ob.disconnect()
|
for (const ob of obs) ob.disconnect()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,20 +15,13 @@ import { Link } from "@/components/link"
|
|||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
|
||||||
|
|
||||||
const all = () => {
|
|
||||||
void import("./dialog-select-provider").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
||||||
@@ -40,11 +33,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(
|
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||||
() =>
|
|
||||||
providers.all().find((x) => x.id === props.provider) ??
|
|
||||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
|
||||||
)
|
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "api" as const,
|
type: "api" as const,
|
||||||
@@ -344,7 +333,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
all()
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
@@ -355,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
dispatch({ type: "method.reset" })
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
all()
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
@@ -20,48 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [state, setState] = createStore({
|
|
||||||
done: false,
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.mcp_ready,
|
|
||||||
(ready, prev) => {
|
|
||||||
if (!ready && prev) setState("done", false)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (state.done || state.loading) return
|
|
||||||
if (sync.data.mcp_ready) {
|
|
||||||
setState("done", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setState("loading", true)
|
|
||||||
void sdk.client.mcp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("mcp", result.data ?? {})
|
|
||||||
sync.set("mcp_ready", true)
|
|
||||||
setState("done", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setState("done", true)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("loading", false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -19,18 +21,6 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const all = () => {
|
|
||||||
void import("./dialog-select-provider").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
@@ -101,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
connect(x.id)
|
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -132,7 +122,9 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
onClick={all}
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.provider.viewAll")}
|
{language.t("dialog.provider.viewAll")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Tag } from "@opencode-ai/ui/tag"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { DialogManageModels } from "./dialog-manage-models"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -86,7 +88,6 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -94,34 +95,24 @@ export function ModelSelectorPopover(props: {
|
|||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
onClose?: (cause: "escape" | "select") => void
|
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: Dismiss | null
|
dismiss: "escape" | "outside" | null
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const close = (dismiss: Dismiss) => {
|
|
||||||
setStore("dismiss", dismiss)
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleManage = () => {
|
const handleManage = () => {
|
||||||
close("manage")
|
setStore("open", false)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
dialog.show(() => <DialogManageModels />)
|
||||||
dialog.show(() => <x.DialogManageModels />)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
setStore("open", false)
|
||||||
void import("./dialog-select-provider").then((x) => {
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -143,19 +134,21 @@ export function ModelSelectorPopover(props: {
|
|||||||
<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"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
close("escape")
|
setStore("dismiss", "escape")
|
||||||
|
setStore("open", false)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={() => close("outside")}
|
onPointerDownOutside={() => {
|
||||||
onFocusOutside={() => close("outside")}
|
setStore("dismiss", "outside")
|
||||||
|
setStore("open", false)
|
||||||
|
}}
|
||||||
|
onFocusOutside={() => {
|
||||||
|
setStore("dismiss", "outside")
|
||||||
|
setStore("open", false)
|
||||||
|
}}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
const dismiss = store.dismiss
|
if (store.dismiss === "outside") event.preventDefault()
|
||||||
if (dismiss === "outside") event.preventDefault()
|
|
||||||
if (dismiss === "escape" || dismiss === "select") {
|
|
||||||
event.preventDefault()
|
|
||||||
props.onClose?.(dismiss)
|
|
||||||
}
|
|
||||||
setStore("dismiss", null)
|
setStore("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -163,7 +156,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
<ModelList
|
<ModelList
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
model={props.model}
|
model={props.model}
|
||||||
onSelect={() => close("select")}
|
onSelect={() => setStore("open", false)}
|
||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -200,29 +193,26 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const provider = () => {
|
|
||||||
void import("./dialog-select-provider").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const manage = () => {
|
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModels />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
action={
|
action={
|
||||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
<Button
|
||||||
|
class="h-7 -my-1 text-14-medium"
|
||||||
|
icon="plus-small"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||||
|
>
|
||||||
{language.t("command.provider.connect")}
|
{language.t("command.provider.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||||
|
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||||
|
>
|
||||||
{language.t("dialog.model.manage")}
|
{language.t("dialog.model.manage")}
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -243,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
|
const tip = () => {
|
||||||
|
if (working()) {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -280,31 +298,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
})
|
})
|
||||||
const blank = createMemo(() => {
|
|
||||||
const text = prompt
|
|
||||||
.current()
|
|
||||||
.map((part) => ("content" in part ? part.content : ""))
|
|
||||||
.join("")
|
|
||||||
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
|
||||||
})
|
|
||||||
const stopping = createMemo(() => working() && blank())
|
|
||||||
const tip = () => {
|
|
||||||
if (stopping()) {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextItems = createMemo(() => {
|
const contextItems = createMemo(() => {
|
||||||
const items = prompt.context.items()
|
const items = prompt.context.items()
|
||||||
@@ -510,15 +503,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return getCursorPosition(editorRef)
|
return getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreFocus = () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
|
||||||
editorRef.focus()
|
|
||||||
setCursorPosition(editorRef, cursor)
|
|
||||||
queueScroll()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderEditorWithCursor = (parts: Prompt) => {
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
const cursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
renderEditor(parts)
|
renderEditor(parts)
|
||||||
@@ -641,18 +625,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
promptProbe.select(cmd.id)
|
promptProbe.select(cmd.id)
|
||||||
closePopover()
|
closePopover()
|
||||||
const images = imageAttachments()
|
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
setEditorText(text)
|
setEditorText(text)
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
focusEditorEnd()
|
focusEditorEnd()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEditor()
|
clearEditor()
|
||||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,6 +1062,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
const acceptLabel = createMemo(() =>
|
||||||
|
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
||||||
|
)
|
||||||
|
const toggleAccept = () => {
|
||||||
|
if (!params.id) {
|
||||||
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
|
}
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
@@ -1322,7 +1316,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
if (
|
||||||
|
target.closest(
|
||||||
|
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
@@ -1346,9 +1344,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
inputMode="text"
|
|
||||||
// @ts-expect-error
|
|
||||||
autocomplete="off"
|
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
@@ -1400,17 +1395,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 pointer-events-auto">
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={store.mode !== "normal" || (!working() && blank())}
|
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={stopping() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
style={buttons()}
|
style={buttons()}
|
||||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1473,10 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
size="normal"
|
size="normal"
|
||||||
options={agentNames()}
|
options={agentNames()}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={(value) => {
|
onSelect={local.agent.set}
|
||||||
local.agent.set(value)
|
|
||||||
restoreFocus()
|
|
||||||
}}
|
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
triggerStyle={control()}
|
triggerStyle={control()}
|
||||||
@@ -1485,7 +1477,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
<Show when={store.mode !== "shell"}>
|
|
||||||
<div data-component="prompt-model-control">
|
<div data-component="prompt-model-control">
|
||||||
<Show
|
<Show
|
||||||
when={providers.paid().length > 0}
|
when={providers.paid().length > 0}
|
||||||
@@ -1503,11 +1494,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
style={control()}
|
style={control()}
|
||||||
onClick={() => {
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1540,7 +1527,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
"data-action": "prompt-model",
|
"data-action": "prompt-model",
|
||||||
}}
|
}}
|
||||||
onClose={restoreFocus}
|
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1569,10 +1555,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
options={variants()}
|
options={variants()}
|
||||||
current={local.model.variant.current() ?? "default"}
|
current={local.model.variant.current() ?? "default"}
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
onSelect={(value) => {
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
local.model.variant.set(value === "default" ? undefined : value)
|
|
||||||
restoreFocus()
|
|
||||||
}}
|
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
triggerStyle={control()}
|
triggerStyle={control()}
|
||||||
@@ -1581,7 +1564,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
title={acceptLabel()}
|
||||||
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-permissions"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleAccept}
|
||||||
|
classList={{
|
||||||
|
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
||||||
|
"text-text-base": !accepting(),
|
||||||
|
"hover:bg-surface-success-base": accepting(),
|
||||||
|
}}
|
||||||
|
style={control()}
|
||||||
|
aria-label={acceptLabel()}
|
||||||
|
aria-pressed={accepting()}
|
||||||
|
>
|
||||||
|
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { onMount } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -182,9 +181,15 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
makeEventListener(document, "dragover", handleGlobalDragOver)
|
document.addEventListener("dragover", handleGlobalDragOver)
|
||||||
makeEventListener(document, "dragleave", handleGlobalDragLeave)
|
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||||
makeEventListener(document, "drop", handleGlobalDrop)
|
document.addEventListener("drop", handleGlobalDrop)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||||
|
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||||
|
document.removeEventListener("drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -100,30 +100,6 @@ describe("buildRequestParts", () => {
|
|||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("adds file parts for @mentions inside comment text", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
|
||||||
context: [
|
|
||||||
{
|
|
||||||
key: "ctx:comment-mention",
|
|
||||||
type: "file",
|
|
||||||
path: "src/review.ts",
|
|
||||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
images: [],
|
|
||||||
text: "look",
|
|
||||||
messageID: "msg_comment_mentions",
|
|
||||||
sessionID: "ses_comment_mentions",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = result.requestParts.filter((part) => part.type === "file")
|
|
||||||
expect(files).toHaveLength(2)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -39,16 +39,6 @@ const absolute = (directory: string, path: string) => {
|
|||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
|
||||||
|
|
||||||
const parseCommentMentions = (comment: string) => {
|
|
||||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
|
||||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
|
||||||
if (!path) return []
|
|
||||||
return [path]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
@@ -148,21 +138,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
|
|
||||||
if (!comment) return [filePart]
|
if (!comment) return [filePart]
|
||||||
|
|
||||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
|
||||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
|
||||||
if (used.has(url)) return []
|
|
||||||
used.add(url)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(path),
|
|
||||||
} satisfies PromptRequestPart,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -178,7 +153,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
...mentions,
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
type PromptImageAttachmentsProps = {
|
||||||
@@ -23,7 +22,6 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
<For each={props.attachments}>
|
<For each={props.attachments}>
|
||||||
{(attachment) => (
|
{(attachment) => (
|
||||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
@@ -52,7 +50,6 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ beforeAll(async () => {
|
|||||||
add: (value: {
|
add: (value: {
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||||
}) => {
|
}) => {
|
||||||
optimistic.push(value)
|
optimistic.push(value)
|
||||||
optimisticSeeded.push(
|
optimisticSeeded.push(
|
||||||
@@ -310,7 +310,8 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
message: {
|
message: {
|
||||||
agent: "agent",
|
agent: "agent",
|
||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
variant: "high",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { promptProbe } from "@/testing/prompt"
|
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
@@ -121,7 +120,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: { ...input.draft.model, variant: input.draft.variant },
|
model: input.draft.model,
|
||||||
|
variant: input.draft.variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = () =>
|
const add = () =>
|
||||||
@@ -307,7 +307,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
promptProbe.start()
|
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
@@ -427,7 +426,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
|
|
||||||
input.onSubmit?.()
|
input.onSubmit?.()
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
||||||
import {
|
import {
|
||||||
children,
|
children,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
type JSXElement,
|
type JSXElement,
|
||||||
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
@@ -46,9 +46,12 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof ResizeObserver !== "function") return
|
|
||||||
createResizeObserver([nameRef, versionRef], check)
|
|
||||||
check()
|
check()
|
||||||
|
if (typeof ResizeObserver !== "function") return
|
||||||
|
const observer = new ResizeObserver(check)
|
||||||
|
if (nameRef) observer.observe(nameRef)
|
||||||
|
if (versionRef) observer.observe(versionRef)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useFile } from "@/context/file"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
@@ -33,7 +32,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
@@ -52,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const context = createMemo(() => metrics().context)
|
const context = createMemo(() => metrics().context)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
|||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
@@ -93,7 +92,6 @@ const emptyUserMessages: UserMessage[] = []
|
|||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
@@ -132,7 +130,7 @@ export function SessionContextTab() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,12 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import {
|
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||||
monoDefault,
|
|
||||||
monoFontFamily,
|
|
||||||
monoInput,
|
|
||||||
sansDefault,
|
|
||||||
sansFontFamily,
|
|
||||||
sansInput,
|
|
||||||
useSettings,
|
|
||||||
} from "@/context/settings"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -37,6 +25,13 @@ type ThemeOption = {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||||
|
|
||||||
|
function loadFont() {
|
||||||
|
font ??= import("@opencode-ai/ui/font-loader")
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
const stopDemoSound = () => {
|
const stopDemoSound = () => {
|
||||||
@@ -67,9 +62,7 @@ const playDemoSound = (id: string | undefined) => {
|
|||||||
export const SettingsGeneral: Component = () => {
|
export const SettingsGeneral: Component = () => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const params = useParams()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -81,31 +74,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
|
||||||
return permission.isAutoAccepting(params.id, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return
|
|
||||||
|
|
||||||
if (!params.id) {
|
|
||||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
|
||||||
permission.toggleAutoAcceptDirectory(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(params.id, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
@@ -169,6 +137,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
||||||
|
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
||||||
|
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
||||||
|
])
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
const languageOptions = createMemo(() =>
|
||||||
language.locales.map((locale) => ({
|
language.locales.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
@@ -176,10 +149,25 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fontOptions = [
|
||||||
|
{ value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
|
||||||
|
{ value: "cascadia-code", label: "font.option.cascadiaCode" },
|
||||||
|
{ value: "fira-code", label: "font.option.firaCode" },
|
||||||
|
{ value: "hack", label: "font.option.hack" },
|
||||||
|
{ value: "inconsolata", label: "font.option.inconsolata" },
|
||||||
|
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
|
||||||
|
{ value: "iosevka", label: "font.option.iosevka" },
|
||||||
|
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
|
||||||
|
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
|
||||||
|
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||||
|
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||||
|
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||||
|
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||||
|
] as const
|
||||||
|
const fontOptionsList = [...fontOptions]
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
const soundSelectProps = (
|
||||||
enabled: () => boolean,
|
enabled: () => boolean,
|
||||||
@@ -231,15 +219,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-auto-accept-permissions">
|
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
@@ -275,6 +254,24 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.followup.title")}
|
||||||
|
description={language.t("settings.general.row.followup.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-followup"
|
||||||
|
options={followupOptions()}
|
||||||
|
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -337,50 +334,31 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.uiFont.title")}
|
|
||||||
description={language.t("settings.general.row.uiFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-ui-font"
|
|
||||||
label={language.t("settings.general.row.uiFont.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={sans()}
|
|
||||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
|
||||||
placeholder={sansDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.font.title")}
|
title={language.t("settings.general.row.font.title")}
|
||||||
description={language.t("settings.general.row.font.description")}
|
description={language.t("settings.general.row.font.description")}
|
||||||
>
|
>
|
||||||
<div class="w-full sm:w-[220px]">
|
<Select
|
||||||
<TextField
|
data-action="settings-font"
|
||||||
data-action="settings-code-font"
|
options={fontOptionsList}
|
||||||
label={language.t("settings.general.row.font.title")}
|
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||||
hideLabel
|
value={(o) => o.value}
|
||||||
type="text"
|
label={(o) => language.t(o.label)}
|
||||||
value={mono()}
|
onHighlight={(option) => {
|
||||||
onChange={(value) => settings.appearance.setFont(value)}
|
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||||
placeholder={monoDefault}
|
}}
|
||||||
spellcheck={false}
|
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||||
autocorrect="off"
|
variant="secondary"
|
||||||
autocomplete="off"
|
size="small"
|
||||||
autocapitalize="off"
|
triggerVariant="settings"
|
||||||
class="text-12-regular"
|
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
>
|
||||||
/>
|
{(option) => (
|
||||||
</div>
|
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||||
|
{option ? language.t(option.label) : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -251,7 +250,8 @@ function useKeyCapture(input: {
|
|||||||
input.stop()
|
input.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
makeEventListener(document, "keydown", handle, { capture: true })
|
document.addEventListener("keydown", handle, true)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handle, true))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,445 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
|
||||||
|
|
||||||
const pollMs = 10_000
|
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
|
||||||
const parts = value.split(file)
|
|
||||||
if (parts.length === 1) return value
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{parts[0]}
|
|
||||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
|
||||||
{parts.slice(1).join(file)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listServersByHealth = (
|
|
||||||
list: ServerConnection.Any[],
|
|
||||||
active: ServerConnection.Key | undefined,
|
|
||||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
|
||||||
) => {
|
|
||||||
if (!list.length) return list
|
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (ServerConnection.key(a) === active) return -1
|
|
||||||
if (ServerConnection.key(b) === active) return 1
|
|
||||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!enabled()) {
|
|
||||||
setStatus(reconcile({}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const list = servers()
|
|
||||||
let dead = false
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const results: Record<string, ServerHealth> = {}
|
|
||||||
await Promise.all(
|
|
||||||
list.map(async (conn) => {
|
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (dead) return
|
|
||||||
setStatus(reconcile(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
void refresh()
|
|
||||||
const id = setInterval(() => void refresh(), pollMs)
|
|
||||||
onCleanup(() => {
|
|
||||||
dead = true
|
|
||||||
clearInterval(id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
const useDefaultServerKey = (
|
|
||||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
|
||||||
) => {
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
url: undefined as string | undefined,
|
|
||||||
tick: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
state.tick
|
|
||||||
let dead = false
|
|
||||||
const result = get?.()
|
|
||||||
if (!result) {
|
|
||||||
setState("url", undefined)
|
|
||||||
onCleanup(() => {
|
|
||||||
dead = true
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.then((next) => {
|
|
||||||
if (dead) return
|
|
||||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
dead = true
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setState("url", normalizeServerUrl(result))
|
|
||||||
onCleanup(() => {
|
|
||||||
dead = true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: () => {
|
|
||||||
const u = state.url
|
|
||||||
if (!u) return
|
|
||||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
|
||||||
},
|
|
||||||
refresh: () => setState("tick", (value) => value + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useMcpToggleMutation = () => {
|
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return useMutation(() => ({
|
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
const status = sync.data.mcp[name]
|
|
||||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|
||||||
const sync = useSync()
|
|
||||||
const server = useServer()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const sdk = useSDK()
|
|
||||||
|
|
||||||
const [load, setLoad] = createStore({
|
|
||||||
lspDone: false,
|
|
||||||
lspLoading: false,
|
|
||||||
mcpDone: false,
|
|
||||||
mcpLoading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!props.shown()) return
|
|
||||||
|
|
||||||
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
|
|
||||||
setLoad("mcpLoading", true)
|
|
||||||
void sdk.client.mcp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("mcp", result.data ?? {})
|
|
||||||
sync.set("mcp_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setLoad("mcpDone", true)
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoad("mcpLoading", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
|
|
||||||
setLoad("lspLoading", true)
|
|
||||||
void sdk.client.lsp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("lsp", result.data ?? [])
|
|
||||||
sync.set("lsp_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setLoad("lspDone", true)
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoad("lspLoading", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let dialogRun = 0
|
|
||||||
let dialogDead = false
|
|
||||||
onCleanup(() => {
|
|
||||||
dialogDead = true
|
|
||||||
dialogRun += 1
|
|
||||||
})
|
|
||||||
const servers = createMemo(() => {
|
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
|
||||||
})
|
|
||||||
const health = useServerHealth(servers, props.shown)
|
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
|
||||||
const toggleMcp = useMcpToggleMutation()
|
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
|
||||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
|
||||||
const plugins = createMemo(() =>
|
|
||||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
|
||||||
)
|
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
|
||||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
|
||||||
<Tabs
|
|
||||||
aria-label={language.t("status.popover.ariaLabel")}
|
|
||||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
|
||||||
data-component="tabs"
|
|
||||||
data-active="servers"
|
|
||||||
defaultValue="servers"
|
|
||||||
variant="alt"
|
|
||||||
>
|
|
||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
|
||||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
|
||||||
{language.t("status.popover.tab.servers")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
|
||||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
|
||||||
{language.t("status.popover.tab.mcp")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
|
||||||
{language.t("status.popover.tab.lsp")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
|
||||||
{language.t("status.popover.tab.plugins")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Content value="servers">
|
|
||||||
<div class="flex flex-col px-2 pb-2">
|
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
|
||||||
<For each={sortedServers()}>
|
|
||||||
{(s) => {
|
|
||||||
const key = ServerConnection.key(s)
|
|
||||||
const blocked = () => health[key]?.healthy === false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
|
||||||
classList={{
|
|
||||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
|
||||||
"cursor-not-allowed": blocked(),
|
|
||||||
}}
|
|
||||||
aria-disabled={blocked()}
|
|
||||||
onClick={() => {
|
|
||||||
if (blocked()) return
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(key))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ServerHealthIndicator health={health[key]} />
|
|
||||||
<ServerRow
|
|
||||||
conn={s}
|
|
||||||
dimmed={blocked()}
|
|
||||||
status={health[key]}
|
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
|
||||||
badge={
|
|
||||||
<Show when={key === defaultServer.key()}>
|
|
||||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
|
||||||
{language.t("common.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex-1" />
|
|
||||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
|
||||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</ServerRow>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
|
||||||
onClick={() => {
|
|
||||||
const run = ++dialogRun
|
|
||||||
void import("./dialog-select-server").then((x) => {
|
|
||||||
if (dialogDead || dialogRun !== run) return
|
|
||||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("status.popover.action.manageServers")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="mcp">
|
|
||||||
<div class="flex flex-col px-2 pb-2">
|
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
|
||||||
<Show
|
|
||||||
when={mcpNames().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={mcpNames()}>
|
|
||||||
{(name) => {
|
|
||||||
const status = () => mcpStatus(name)
|
|
||||||
const enabled = () => status() === "connected"
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
|
||||||
onClick={() => {
|
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
|
||||||
"bg-icon-success-base": status() === "connected",
|
|
||||||
"bg-icon-critical-base": status() === "failed",
|
|
||||||
"bg-border-weak-base": status() === "disabled",
|
|
||||||
"bg-icon-warning-base":
|
|
||||||
status() === "needs_auth" || status() === "needs_client_registration",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
|
||||||
<div onClick={(event) => event.stopPropagation()}>
|
|
||||||
<Switch
|
|
||||||
checked={enabled()}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
onChange={() => {
|
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="lsp">
|
|
||||||
<div class="flex flex-col px-2 pb-2">
|
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
|
||||||
<Show
|
|
||||||
when={lspItems().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={lspItems()}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
|
||||||
"bg-icon-success-base": item.status === "connected",
|
|
||||||
"bg-icon-critical-base": item.status === "error",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="plugins">
|
|
||||||
<div class="flex flex-col px-2 pb-2">
|
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
|
||||||
<Show
|
|
||||||
when={plugins().length > 0}
|
|
||||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
|
||||||
>
|
|
||||||
<For each={plugins()}>
|
|
||||||
{(plugin) => (
|
|
||||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
|
||||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
|
||||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,202 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServer } from "@/context/server"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const pollMs = 10_000
|
||||||
|
|
||||||
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
|
const parts = value.split(file)
|
||||||
|
if (parts.length === 1) return value
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts[0]}
|
||||||
|
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||||
|
{parts.slice(1).join(file)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listServersByHealth = (
|
||||||
|
list: ServerConnection.Any[],
|
||||||
|
active: ServerConnection.Key | undefined,
|
||||||
|
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
|
) => {
|
||||||
|
if (!list.length) return list
|
||||||
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (ServerConnection.key(a) === active) return -1
|
||||||
|
if (ServerConnection.key(b) === active) return 1
|
||||||
|
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!enabled()) {
|
||||||
|
setStatus(reconcile({}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = servers()
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const results: Record<string, ServerHealth> = {}
|
||||||
|
await Promise.all(
|
||||||
|
list.map(async (conn) => {
|
||||||
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (dead) return
|
||||||
|
setStatus(reconcile(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh()
|
||||||
|
const id = setInterval(() => void refresh(), pollMs)
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
clearInterval(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDefaultServerKey = (
|
||||||
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
|
) => {
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
url: undefined as string | undefined,
|
||||||
|
tick: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
state.tick
|
||||||
|
let dead = false
|
||||||
|
const result = get?.()
|
||||||
|
if (!result) {
|
||||||
|
setState("url", undefined)
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.then((next) => {
|
||||||
|
if (dead) return
|
||||||
|
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("url", normalizeServerUrl(result))
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: () => {
|
||||||
|
const u = state.url
|
||||||
|
if (!u) return
|
||||||
|
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||||
|
},
|
||||||
|
refresh: () => setState("tick", (value) => value + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMcpToggleMutation = () => {
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return useMutation(() => ({
|
||||||
|
mutationFn: async (name: string) => {
|
||||||
|
const status = sync.data.mcp[name]
|
||||||
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
|
const result = await sdk.client.mcp.status()
|
||||||
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const language = useLanguage()
|
|
||||||
const server = useServer()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
let dialogRun = 0
|
||||||
const healthy = createMemo(() => {
|
let dialogDead = false
|
||||||
|
onCleanup(() => {
|
||||||
|
dialogDead = true
|
||||||
|
dialogRun += 1
|
||||||
|
})
|
||||||
|
const servers = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
|
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||||
|
})
|
||||||
|
const health = useServerHealth(servers, shown)
|
||||||
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
|
const toggleMcp = useMcpToggleMutation()
|
||||||
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
|
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||||
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
|
const overallHealthy = createMemo(() => {
|
||||||
const serverHealthy = server.healthy() === true
|
const serverHealthy = server.healthy() === true
|
||||||
const mcp = Object.values(sync.data.mcp ?? {})
|
const anyMcpIssue = mcpNames().some((name) => {
|
||||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
const status = mcpStatus(name)
|
||||||
return serverHealthy && !issue
|
return status !== "connected" && status !== "disabled"
|
||||||
|
})
|
||||||
|
return serverHealthy && !anyMcpIssue
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,9 +218,9 @@ export function StatusPopover() {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||||
"bg-icon-success-base": ready() && healthy(),
|
"bg-icon-success-base": overallHealthy(),
|
||||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,15 +230,205 @@ export function StatusPopover() {
|
|||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
shift={-168}
|
shift={-168}
|
||||||
>
|
>
|
||||||
<Show when={shown()}>
|
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||||
<Suspense
|
<Tabs
|
||||||
fallback={
|
aria-label={language.t("status.popover.ariaLabel")}
|
||||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||||
|
data-component="tabs"
|
||||||
|
data-active="servers"
|
||||||
|
defaultValue="servers"
|
||||||
|
variant="alt"
|
||||||
|
>
|
||||||
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
|
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||||
|
{language.t("status.popover.tab.servers")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||||
|
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||||
|
{language.t("status.popover.tab.mcp")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||||
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
|
{language.t("status.popover.tab.lsp")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="servers">
|
||||||
|
<div class="flex flex-col px-2 pb-2">
|
||||||
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
<For each={sortedServers()}>
|
||||||
|
{(s) => {
|
||||||
|
const key = ServerConnection.key(s)
|
||||||
|
const isBlocked = () => health[key]?.healthy === false
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||||
|
classList={{
|
||||||
|
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||||
|
"cursor-not-allowed": isBlocked(),
|
||||||
|
}}
|
||||||
|
aria-disabled={isBlocked()}
|
||||||
|
onClick={() => {
|
||||||
|
if (isBlocked()) return
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(key))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ServerHealthIndicator health={health[key]} />
|
||||||
|
<ServerRow
|
||||||
|
conn={s}
|
||||||
|
dimmed={isBlocked()}
|
||||||
|
status={health[key]}
|
||||||
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
badge={
|
||||||
|
<Show when={key === defaultServer.key()}>
|
||||||
|
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||||
|
{language.t("common.default")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Body shown={shown} />
|
<div class="flex-1" />
|
||||||
</Suspense>
|
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||||
|
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
|
</ServerRow>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||||
|
onClick={() => {
|
||||||
|
const run = ++dialogRun
|
||||||
|
void import("./dialog-select-server").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("status.popover.action.manageServers")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="mcp">
|
||||||
|
<div class="flex flex-col px-2 pb-2">
|
||||||
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
<Show
|
||||||
|
when={mcpNames().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="text-14-regular text-text-base text-center my-auto">
|
||||||
|
{language.t("dialog.mcp.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={mcpNames()}>
|
||||||
|
{(name) => {
|
||||||
|
const status = () => mcpStatus(name)
|
||||||
|
const enabled = () => status() === "connected"
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => {
|
||||||
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": status() === "connected",
|
||||||
|
"bg-icon-critical-base": status() === "failed",
|
||||||
|
"bg-border-weak-base": status() === "disabled",
|
||||||
|
"bg-icon-warning-base":
|
||||||
|
status() === "needs_auth" || status() === "needs_client_registration",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||||
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
|
<Switch
|
||||||
|
checked={enabled()}
|
||||||
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
|
onChange={() => {
|
||||||
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="lsp">
|
||||||
|
<div class="flex flex-col px-2 pb-2">
|
||||||
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
<Show
|
||||||
|
when={lspItems().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="text-14-regular text-text-base text-center my-auto">
|
||||||
|
{language.t("dialog.lsp.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={lspItems()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": item.status === "connected",
|
||||||
|
"bg-icon-critical-base": item.status === "error",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="plugins">
|
||||||
|
<div class="flex flex-col px-2 pb-2">
|
||||||
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
<Show
|
||||||
|
when={plugins().length > 0}
|
||||||
|
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||||
|
>
|
||||||
|
<For each={plugins()}>
|
||||||
|
{(plugin) => (
|
||||||
|
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||||
|
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||||
|
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const auth = server.current?.http
|
const auth = server.current?.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
const password = auth?.password ?? ""
|
const password = auth?.password ?? ""
|
||||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
@@ -520,12 +519,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
next.searchParams.set("directory", directory)
|
next.searchParams.set("directory", directory)
|
||||||
next.searchParams.set("cursor", String(seek))
|
next.searchParams.set("cursor", String(seek))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (!sameOrigin && password) {
|
|
||||||
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
|
||||||
// For same-origin requests, let the browser reuse the page's existing auth.
|
|
||||||
next.username = username
|
next.username = username
|
||||||
next.password = password
|
next.password = password
|
||||||
}
|
|
||||||
|
|
||||||
const socket = new WebSocket(next)
|
const socket = new WebSocket(next)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
@@ -379,7 +378,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
makeEventListener(document, "keydown", handleKeyDown)
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
function register(cb: () => CommandOption[]): void
|
function register(cb: () => CommandOption[]): void
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { batch, onCleanup } from "solid-js"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
@@ -106,8 +105,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
const aborted = (error: unknown) => abortError.safeParse(error).success
|
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||||
|
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
let run: Promise<void> | undefined
|
|
||||||
let started = false
|
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
let lastEventAt = Date.now()
|
let lastEventAt = Date.now()
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -124,11 +121,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
heartbeat = undefined
|
heartbeat = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = () => {
|
void (async () => {
|
||||||
if (started) return run
|
while (!abort.signal.aborted) {
|
||||||
started = true
|
|
||||||
run = (async () => {
|
|
||||||
while (!abort.signal.aborted && started) {
|
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
lastEventAt = Date.now()
|
lastEventAt = Date.now()
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
@@ -191,33 +185,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
clearHeartbeat()
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted || !started) return
|
if (abort.signal.aborted) return
|
||||||
await wait(RECONNECT_DELAY_MS)
|
await wait(RECONNECT_DELAY_MS)
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
})().finally(flush)
|
||||||
run = undefined
|
|
||||||
flush()
|
|
||||||
})
|
|
||||||
return run
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = () => {
|
const onVisibility = () => {
|
||||||
started = false
|
if (typeof document === "undefined") return
|
||||||
attempt?.abort()
|
|
||||||
clearHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
makeEventListener(document, "visibilitychange", () => {
|
|
||||||
if (document.visibilityState !== "visible") return
|
if (document.visibilityState !== "visible") return
|
||||||
if (!started) return
|
|
||||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
})
|
}
|
||||||
})
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", onVisibility)
|
||||||
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
if (typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility)
|
||||||
|
}
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
@@ -231,11 +217,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
return {
|
return {
|
||||||
url: currentServer.http.url,
|
url: currentServer.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
event: {
|
event: emitter,
|
||||||
on: emitter.on.bind(emitter),
|
|
||||||
listen: emitter.listen.bind(emitter),
|
|
||||||
start,
|
|
||||||
},
|
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
const s = server.current
|
const s = server.current
|
||||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
@@ -72,16 +72,10 @@ function createGlobalSync() {
|
|||||||
let projectWritten = false
|
let projectWritten = false
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
let eventFrame: number | undefined
|
|
||||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
active = false
|
active = false
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
|
||||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
|
||||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
|
||||||
})
|
|
||||||
|
|
||||||
const cacheProjects = () => {
|
const cacheProjects = () => {
|
||||||
setProjectCache(
|
setProjectCache(
|
||||||
@@ -160,7 +154,6 @@ function createGlobalSync() {
|
|||||||
queue.clear(directory)
|
queue.clear(directory)
|
||||||
sessionMeta.delete(directory)
|
sessionMeta.delete(directory)
|
||||||
sdkCache.delete(directory)
|
sdkCache.delete(directory)
|
||||||
clearProviderRev(directory)
|
|
||||||
clearSessionPrefetchDirectory(directory)
|
clearSessionPrefetchDirectory(directory)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
@@ -259,7 +252,6 @@ function createGlobalSync() {
|
|||||||
directory,
|
directory,
|
||||||
global: {
|
global: {
|
||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
@@ -319,10 +311,7 @@ function createGlobalSync() {
|
|||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
sdkFor(directory)
|
sdkFor(directory)
|
||||||
.lsp.status()
|
.lsp.status()
|
||||||
.then((x) => {
|
.then((x) => setStore("lsp", x.data ?? []))
|
||||||
setStore("lsp", x.data ?? [])
|
|
||||||
setStore("lsp_ready", true)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -354,20 +343,6 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof requestAnimationFrame === "function") {
|
|
||||||
eventFrame = requestAnimationFrame(() => {
|
|
||||||
eventFrame = undefined
|
|
||||||
eventTimer = setTimeout(() => {
|
|
||||||
eventTimer = undefined
|
|
||||||
globalSDK.event.start()
|
|
||||||
}, 0)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
eventTimer = setTimeout(() => {
|
|
||||||
eventTimer = undefined
|
|
||||||
globalSDK.event.start()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
@@ -43,10 +42,8 @@ function waitForPaint() {
|
|||||||
const timer = setTimeout(finish, 50)
|
const timer = setTimeout(finish, 50)
|
||||||
if (typeof requestAnimationFrame !== "function") return
|
if (typeof requestAnimationFrame !== "function") return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setTimeout(() => {
|
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
finish()
|
finish()
|
||||||
}, 0)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -55,12 +52,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
|||||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerRev = new Map<string, number>()
|
|
||||||
|
|
||||||
export function clearProviderRev(directory: string) {
|
|
||||||
providerRev.delete(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function runAll(list: Array<() => Promise<unknown>>) {
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
return Promise.allSettled(list.map((item) => item()))
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
}
|
}
|
||||||
@@ -89,6 +80,12 @@ export async function bootstrapGlobal(input: {
|
|||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
}) {
|
}) {
|
||||||
const fast = [
|
const fast = [
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.path.get().then((x) => {
|
||||||
|
input.setGlobalStore("path", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
input.globalSDK.global.config.get().then((x) => {
|
||||||
@@ -104,12 +101,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const slow = [
|
const slow = [
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.path.get().then((x) => {
|
|
||||||
input.setGlobalStore("path", x.data!)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.globalSDK.project.list().then((x) => {
|
input.globalSDK.project.list().then((x) => {
|
||||||
@@ -153,40 +144,6 @@ function projectID(directory: string, projects: Project[]) {
|
|||||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
|
||||||
setStore("session", (list) => {
|
|
||||||
const next = list.slice()
|
|
||||||
const idx = next.findIndex((item) => item.id >= session.id)
|
|
||||||
if (idx === -1) return [...next, session]
|
|
||||||
if (next[idx]?.id === session.id) {
|
|
||||||
next[idx] = session
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
next.splice(idx, 0, session)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function warmSessions(input: {
|
|
||||||
ids: string[]
|
|
||||||
store: Store<State>
|
|
||||||
setStore: SetStoreFunction<State>
|
|
||||||
sdk: OpencodeClient
|
|
||||||
}) {
|
|
||||||
const known = new Set(input.store.session.map((item) => item.id))
|
|
||||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
|
||||||
if (ids.length === 0) return Promise.resolve()
|
|
||||||
return Promise.all(
|
|
||||||
ids.map((sessionID) =>
|
|
||||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
|
||||||
const session = x.data
|
|
||||||
if (!session?.id) return
|
|
||||||
mergeSession(input.setStore, session)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).then(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
@@ -197,52 +154,37 @@ export async function bootstrapDirectory(input: {
|
|||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
global: {
|
global: {
|
||||||
config: Config
|
config: Config
|
||||||
path: Path
|
|
||||||
project: Project[]
|
project: Project[]
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
|
||||||
if (seededProject) input.setStore("project", seededProject)
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
if (seededPath) input.setStore("path", seededPath)
|
|
||||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||||
input.setStore("provider", input.global.provider)
|
input.setStore("provider", input.global.provider)
|
||||||
}
|
}
|
||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
input.setStore("config", input.global.config)
|
input.setStore("config", input.global.config)
|
||||||
}
|
}
|
||||||
if (loading || input.store.provider.all.length === 0) {
|
|
||||||
input.setStore("provider_ready", false)
|
|
||||||
}
|
|
||||||
input.setStore("mcp_ready", false)
|
|
||||||
input.setStore("mcp", {})
|
|
||||||
input.setStore("lsp_ready", false)
|
|
||||||
input.setStore("lsp", [])
|
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const fast = [
|
const fast = [
|
||||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
|
||||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
|
||||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
|
||||||
]
|
|
||||||
|
|
||||||
const slow = [
|
|
||||||
() =>
|
() =>
|
||||||
seededProject
|
seededProject
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||||
|
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||||
() =>
|
() =>
|
||||||
seededPath
|
retry(() =>
|
||||||
? Promise.resolve()
|
|
||||||
: retry(() =>
|
|
||||||
input.sdk.path.get().then((x) => {
|
input.sdk.path.get().then((x) => {
|
||||||
input.setStore("path", x.data!)
|
input.setStore("path", x.data!)
|
||||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||||
if (next) input.setStore("project", next)
|
if (next) input.setStore("project", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.vcs.get().then((x) => {
|
input.sdk.vcs.get().then((x) => {
|
||||||
@@ -255,11 +197,9 @@ export async function bootstrapDirectory(input: {
|
|||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
input.sdk.permission.list().then((x) => {
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||||
)
|
)
|
||||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const sessionID of Object.keys(input.store.permission)) {
|
for (const sessionID of Object.keys(input.store.permission)) {
|
||||||
if (grouped[sessionID]) continue
|
if (grouped[sessionID]) continue
|
||||||
@@ -275,16 +215,13 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.question.list().then((x) => {
|
input.sdk.question.list().then((x) => {
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const sessionID of Object.keys(input.store.question)) {
|
for (const sessionID of Object.keys(input.store.question)) {
|
||||||
if (grouped[sessionID]) continue
|
if (grouped[sessionID]) continue
|
||||||
@@ -300,18 +237,21 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const slow = [
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.provider.list().then((x) => {
|
||||||
|
input.setStore("provider", normalizeProviderList(x.data!))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||||
retry(() =>
|
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||||
input.sdk.mcp.status().then((x) => {
|
|
||||||
input.setStore("mcp", x.data!)
|
|
||||||
input.setStore("mcp_ready", true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const errs = errors(await runAll(fast))
|
const errs = errors(await runAll(fast))
|
||||||
@@ -338,23 +278,4 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
|
|
||||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
|
||||||
providerRev.set(input.directory, rev)
|
|
||||||
void retry(() => input.sdk.provider.list())
|
|
||||||
.then((x) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) return
|
|
||||||
input.setStore("provider", normalizeProviderList(x.data!))
|
|
||||||
input.setStore("provider_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) return
|
|
||||||
console.error("Failed to refresh provider list", err)
|
|
||||||
const project = getFilename(input.directory)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
|
||||||
description: formatServerError(err, input.translate),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export function createChildStoreManager(input: {
|
|||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
provider_ready: false,
|
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
@@ -174,9 +173,7 @@ export function createChildStoreManager(input: {
|
|||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
mcp_ready: false,
|
|
||||||
mcp: {},
|
mcp: {},
|
||||||
lsp_ready: false,
|
|
||||||
lsp: [],
|
lsp: [],
|
||||||
vcs: vcsStore.value,
|
vcs: vcsStore.value,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type {
|
import type {
|
||||||
|
FileDiff,
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
@@ -8,13 +9,11 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -162,8 +161,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
@@ -178,7 +177,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = clean((event.properties as { info: Message }).info)
|
const info = (event.properties as { info: Message }).info
|
||||||
const messages = input.store.message[info.sessionID]
|
const messages = input.store.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type {
|
||||||
|
FileDiff,
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
@@ -33,7 +33,7 @@ describe("app session cache", () => {
|
|||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
@@ -64,7 +64,7 @@ describe("app session cache", () => {
|
|||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
FileDiff,
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
|||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
Command,
|
Command,
|
||||||
Config,
|
Config,
|
||||||
|
FileDiff,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
Message,
|
Message,
|
||||||
@@ -13,7 +14,6 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -38,7 +38,6 @@ export type State = {
|
|||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
icon: string | undefined
|
icon: string | undefined
|
||||||
provider_ready: boolean
|
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
@@ -48,7 +47,7 @@ export type State = {
|
|||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
}
|
}
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SnapshotFileDiff[]
|
[sessionID: string]: FileDiff[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
@@ -59,11 +58,9 @@ export type State = {
|
|||||||
question: {
|
question: {
|
||||||
[sessionID: string]: QuestionRequest[]
|
[sessionID: string]: QuestionRequest[]
|
||||||
}
|
}
|
||||||
mcp_ready: boolean
|
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpStatus
|
||||||
}
|
}
|
||||||
lsp_ready: boolean
|
|
||||||
lsp: LspStatus[]
|
lsp: LspStatus[]
|
||||||
vcs: VcsInfo | undefined
|
vcs: VcsInfo | undefined
|
||||||
limit: number
|
limit: number
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
@@ -14,8 +13,7 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
|||||||
import { createPathHelpers } from "./file/path"
|
import { createPathHelpers } from "./file/path"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 344
|
const DEFAULT_PANEL_WIDTH = 344
|
||||||
const DEFAULT_FILE_TREE_WIDTH = 200
|
|
||||||
const DEFAULT_SESSION_WIDTH = 600
|
const DEFAULT_SESSION_WIDTH = 600
|
||||||
const DEFAULT_TERMINAL_HEIGHT = 280
|
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
@@ -163,11 +161,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (!isRecord(fileTree)) return fileTree
|
if (!isRecord(fileTree)) return fileTree
|
||||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||||
|
|
||||||
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
|
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
|
||||||
return {
|
return {
|
||||||
...fileTree,
|
...fileTree,
|
||||||
opened: true,
|
opened: true,
|
||||||
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
|
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
|
||||||
tab: "changes",
|
tab: "changes",
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -232,7 +230,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
width: DEFAULT_SIDEBAR_WIDTH,
|
width: DEFAULT_PANEL_WIDTH,
|
||||||
workspaces: {} as Record<string, boolean>,
|
workspaces: {} as Record<string, boolean>,
|
||||||
workspacesDefault: false,
|
workspacesDefault: false,
|
||||||
},
|
},
|
||||||
@@ -245,8 +243,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
panelOpened: true,
|
panelOpened: true,
|
||||||
},
|
},
|
||||||
fileTree: {
|
fileTree: {
|
||||||
opened: false,
|
opened: true,
|
||||||
width: DEFAULT_FILE_TREE_WIDTH,
|
width: DEFAULT_PANEL_WIDTH,
|
||||||
tab: "changes" as "changes" | "all",
|
tab: "changes" as "changes" | "all",
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -367,10 +365,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
makeEventListener(window, "pagehide", flush)
|
window.addEventListener("pagehide", flush)
|
||||||
makeEventListener(document, "visibilitychange", handleVisibility)
|
document.addEventListener("visibilitychange", handleVisibility)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pagehide", flush)
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility)
|
||||||
scroll.dispose()
|
scroll.dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -543,26 +543,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let sessionFrame: number | undefined
|
|
||||||
let sessionTimer: number | undefined
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
sessionFrame = requestAnimationFrame(() => {
|
Promise.all(
|
||||||
sessionFrame = undefined
|
|
||||||
sessionTimer = window.setTimeout(() => {
|
|
||||||
sessionTimer = undefined
|
|
||||||
void Promise.all(
|
|
||||||
server.projects.list().map((project) => {
|
server.projects.list().map((project) => {
|
||||||
return globalSync.project.loadSessions(project.worktree)
|
return globalSync.project.loadSessions(project.worktree)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}, 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
|
|
||||||
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -642,32 +628,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
fileTree: {
|
fileTree: {
|
||||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||||
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
|
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
|
||||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||||
setTab(tab: "changes" | "all") {
|
setTab(tab: "changes" | "all") {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "tab", tab)
|
setStore("fileTree", "tab", tab)
|
||||||
},
|
},
|
||||||
open() {
|
open() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", true)
|
setStore("fileTree", "opened", true)
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", false)
|
setStore("fileTree", "opened", false)
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", (x) => !x)
|
setStore("fileTree", "opened", (x) => !x)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
|
||||||
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
export type ModelKey = { providerID: string; modelID: string }
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
handoff.set(handoffKey(dir, session), next)
|
handoff.set(handoffKey(dir, session), next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
@@ -383,25 +383,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
model: msg.model,
|
model: msg.model,
|
||||||
variant: msg.model.variant ?? null,
|
variant: msg.variant ?? null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelEnabled()) {
|
if (modelEnabled()) {
|
||||||
const probe = Symbol("model-probe")
|
|
||||||
|
|
||||||
modelProbe.bind(probe, {
|
|
||||||
setAgent: agent.set,
|
|
||||||
setModel: model.set,
|
|
||||||
setVariant: model.variant.set,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const agent = result.agent.current()
|
const agent = result.agent.current()
|
||||||
const model = result.model.current()
|
const model = result.model.current()
|
||||||
modelProbe.set(probe, {
|
modelProbe.set({
|
||||||
dir: sdk.directory,
|
dir: sdk.directory,
|
||||||
sessionID: id(),
|
sessionID: id(),
|
||||||
last: store.last,
|
last: store.last,
|
||||||
@@ -419,20 +411,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
pick: scope(),
|
pick: scope(),
|
||||||
base: undefined,
|
base: undefined,
|
||||||
current: store.current,
|
current: store.current,
|
||||||
variants: result.model.variant.list(),
|
|
||||||
models: result.model
|
|
||||||
.list()
|
|
||||||
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
|
|
||||||
.map((item) => ({
|
|
||||||
providerID: item.provider.id,
|
|
||||||
modelID: item.id,
|
|
||||||
name: item.name,
|
|
||||||
})),
|
|
||||||
agents: result.agent.list().map((item) => ({ name: item.name })),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => modelProbe.clear(probe))
|
onCleanup(() => modelProbe.clear())
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -94,11 +94,7 @@ export namespace ServerConnection {
|
|||||||
|
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: {
|
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||||
defaultServer: ServerConnection.Key
|
|
||||||
disableHealthCheck?: boolean
|
|
||||||
servers?: Array<ServerConnection.Any>
|
|
||||||
}) => {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
@@ -206,10 +202,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const current_ = current()
|
const current_ = current()
|
||||||
if (!current_) return
|
if (!current_) return
|
||||||
|
|
||||||
if (props.disableHealthCheck) {
|
|
||||||
setState("healthy", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState("healthy", undefined)
|
setState("healthy", undefined)
|
||||||
onCleanup(startHealthPolling(current_))
|
onCleanup(startHealthPolling(current_))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ export interface Settings {
|
|||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
mono: string
|
font: string
|
||||||
sans: string
|
|
||||||
}
|
}
|
||||||
keybinds: Record<string, string>
|
keybinds: Record<string, string>
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -43,54 +42,13 @@ export interface Settings {
|
|||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const monoDefault = "System Mono"
|
|
||||||
export const sansDefault = "System Sans"
|
|
||||||
|
|
||||||
const monoFallback =
|
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
||||||
|
|
||||||
const monoBase = monoFallback
|
|
||||||
const sansBase = sansFallback
|
|
||||||
|
|
||||||
function input(font: string | undefined) {
|
|
||||||
return font ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function family(font: string) {
|
|
||||||
if (/^[\w-]+$/.test(font)) return font
|
|
||||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
function stack(font: string | undefined, base: string) {
|
|
||||||
const value = font?.trim() ?? ""
|
|
||||||
if (!value) return base
|
|
||||||
return `${family(value)}, ${base}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function monoInput(font: string | undefined) {
|
|
||||||
return input(font)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sansInput(font: string | undefined) {
|
|
||||||
return input(font)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function monoFontFamily(font: string | undefined) {
|
|
||||||
return stack(font, monoBase)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sansFontFamily(font: string | undefined) {
|
|
||||||
return stack(font, sansBase)
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
general: {
|
general: {
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
releaseNotes: true,
|
releaseNotes: true,
|
||||||
followup: "steer",
|
followup: "steer",
|
||||||
showReasoningSummaries: false,
|
showReasoningSummaries: false,
|
||||||
shellToolPartsExpanded: false,
|
shellToolPartsExpanded: true,
|
||||||
editToolPartsExpanded: false,
|
editToolPartsExpanded: false,
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
@@ -98,8 +56,7 @@ const defaultSettings: Settings = {
|
|||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
mono: "",
|
font: "ibm-plex-mono",
|
||||||
sans: "",
|
|
||||||
},
|
},
|
||||||
keybinds: {},
|
keybinds: {},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -120,10 +77,40 @@ const defaultSettings: Settings = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const monoFallback =
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
|
|
||||||
|
const monoFonts: Record<string, string> = {
|
||||||
|
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monoFontFamily(font: string | undefined) {
|
||||||
|
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||||
|
}
|
||||||
|
|
||||||
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||||
return createMemo(() => read() ?? fallback)
|
return createMemo(() => read() ?? fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||||
|
|
||||||
|
function loadFont() {
|
||||||
|
font ??= import("@opencode-ai/ui/font-loader")
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -131,14 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
if (id !== defaultSettings.appearance.font) {
|
||||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||||
})
|
}
|
||||||
|
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||||
createEffect(() => {
|
|
||||||
if (store.general?.followup !== "queue") return
|
|
||||||
setStore("general", "followup", "steer")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -155,12 +139,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setReleaseNotes(value: boolean) {
|
setReleaseNotes(value: boolean) {
|
||||||
setStore("general", "releaseNotes", value)
|
setStore("general", "releaseNotes", value)
|
||||||
},
|
},
|
||||||
followup: withFallback(
|
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
||||||
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
|
||||||
defaultSettings.general.followup,
|
|
||||||
),
|
|
||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
setStore("general", "followup", value)
|
||||||
},
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
@@ -195,13 +176,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
setStore("appearance", "fontSize", value)
|
setStore("appearance", "fontSize", value)
|
||||||
},
|
},
|
||||||
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
|
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||||
setFont(value: string) {
|
setFont(value: string) {
|
||||||
setStore("appearance", "mono", value.trim() ? value : "")
|
setStore("appearance", "font", value)
|
||||||
},
|
|
||||||
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
|
|
||||||
setUIFont(value: string) {
|
|
||||||
setStore("appearance", "sans", value.trim() ? value : "")
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
keybinds: {
|
keybinds: {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useGlobalSync } from "./global-sync"
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -301,7 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
return {
|
return {
|
||||||
@@ -417,7 +416,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: { ...input.model, variant: input.variant },
|
model: input.model,
|
||||||
|
variant: input.variant,
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
||||||
@@ -510,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
|||||||
const providers = () => {
|
const providers = () => {
|
||||||
if (dir()) {
|
if (dir()) {
|
||||||
const [projectStore] = globalSync.child(dir())
|
const [projectStore] = globalSync.child(dir())
|
||||||
if (projectStore.provider_ready) return projectStore.provider
|
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||||
}
|
}
|
||||||
return globalSync.data.provider
|
return globalSync.data.provider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,10 +564,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
||||||
"settings.general.row.theme.title": "السمة",
|
"settings.general.row.theme.title": "السمة",
|
||||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||||
"settings.general.row.font.title": "خط الكود",
|
"settings.general.row.font.title": "الخط",
|
||||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
|
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
|
||||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
|
||||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||||
@@ -594,6 +592,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
||||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "بلا",
|
"sound.option.none": "بلا",
|
||||||
"sound.option.alert01": "تنبيه 01",
|
"sound.option.alert01": "تنبيه 01",
|
||||||
"sound.option.alert02": "تنبيه 02",
|
"sound.option.alert02": "تنبيه 02",
|
||||||
|
|||||||
@@ -571,10 +571,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||||
"settings.general.row.font.title": "Fonte de código",
|
"settings.general.row.font.title": "Fonte",
|
||||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
|
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
|
||||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
|
||||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||||
"settings.general.row.followup.description":
|
"settings.general.row.followup.description":
|
||||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||||
@@ -602,6 +600,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Verificando...",
|
"settings.updates.action.checking": "Verificando...",
|
||||||
"settings.updates.toast.latest.title": "Você está atualizado",
|
"settings.updates.toast.latest.title": "Você está atualizado",
|
||||||
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Nenhum",
|
"sound.option.none": "Nenhum",
|
||||||
"sound.option.alert01": "Alerta 01",
|
"sound.option.alert01": "Alerta 01",
|
||||||
"sound.option.alert02": "Alerta 02",
|
"sound.option.alert02": "Alerta 02",
|
||||||
|
|||||||
@@ -636,10 +636,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||||
"settings.general.row.font.title": "Font za kod",
|
"settings.general.row.font.title": "Font",
|
||||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
|
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||||
"settings.general.row.uiFont.title": "UI font",
|
|
||||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
|
||||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||||
@@ -669,6 +667,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Provjera...",
|
"settings.updates.action.checking": "Provjera...",
|
||||||
"settings.updates.toast.latest.title": "Sve je ažurno",
|
"settings.updates.toast.latest.title": "Sve je ažurno",
|
||||||
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Nijedan",
|
"sound.option.none": "Nijedan",
|
||||||
"sound.option.alert01": "Upozorenje 01",
|
"sound.option.alert01": "Upozorenje 01",
|
||||||
"sound.option.alert02": "Upozorenje 02",
|
"sound.option.alert02": "Upozorenje 02",
|
||||||
|
|||||||
@@ -631,10 +631,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||||
"settings.general.row.font.title": "Kode-skrifttype",
|
"settings.general.row.font.title": "Skrifttype",
|
||||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
|
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
|
||||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
|
||||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||||
"settings.general.row.followup.option.queue": "Kø",
|
"settings.general.row.followup.option.queue": "Kø",
|
||||||
@@ -664,6 +662,19 @@ export const dict = {
|
|||||||
"settings.updates.toast.latest.title": "Du er opdateret",
|
"settings.updates.toast.latest.title": "Du er opdateret",
|
||||||
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
|
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
|
||||||
|
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Ingen",
|
"sound.option.none": "Ingen",
|
||||||
"sound.option.alert01": "Alarm 01",
|
"sound.option.alert01": "Alarm 01",
|
||||||
"sound.option.alert02": "Alarm 02",
|
"sound.option.alert02": "Alarm 02",
|
||||||
|
|||||||
@@ -581,10 +581,8 @@ export const dict = {
|
|||||||
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
|
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
|
||||||
"settings.general.row.theme.title": "Thema",
|
"settings.general.row.theme.title": "Thema",
|
||||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||||
"settings.general.row.font.title": "Code-Schriftart",
|
"settings.general.row.font.title": "Schriftart",
|
||||||
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
|
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
|
||||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
|
||||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||||
"settings.general.row.followup.description":
|
"settings.general.row.followup.description":
|
||||||
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
||||||
@@ -613,6 +611,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Wird geprüft...",
|
"settings.updates.action.checking": "Wird geprüft...",
|
||||||
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
|
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
|
||||||
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
|
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Keine",
|
"sound.option.none": "Keine",
|
||||||
"sound.option.alert01": "Alarm 01",
|
"sound.option.alert01": "Alarm 01",
|
||||||
"sound.option.alert02": "Alarm 02",
|
"sound.option.alert02": "Alarm 02",
|
||||||
|
|||||||
@@ -238,8 +238,6 @@ export const dict = {
|
|||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
"prompt.mode.normal": "Prompt",
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc to exit",
|
"prompt.mode.shell.exit": "esc to exit",
|
||||||
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
|
|
||||||
"session.child.backToParent": "Back to main session.",
|
|
||||||
|
|
||||||
"prompt.example.1": "Fix a TODO in the codebase",
|
"prompt.example.1": "Fix a TODO in the codebase",
|
||||||
"prompt.example.2": "What is the tech stack of this project?",
|
"prompt.example.2": "What is the tech stack of this project?",
|
||||||
@@ -733,10 +731,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
||||||
"settings.general.row.theme.title": "Theme",
|
"settings.general.row.theme.title": "Theme",
|
||||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||||
"settings.general.row.font.title": "Code Font",
|
"settings.general.row.font.title": "Font",
|
||||||
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
|
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||||
"settings.general.row.uiFont.title": "UI Font",
|
|
||||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
|
||||||
"settings.general.row.followup.title": "Follow-up behavior",
|
"settings.general.row.followup.title": "Follow-up behavior",
|
||||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||||
"settings.general.row.followup.option.queue": "Queue",
|
"settings.general.row.followup.option.queue": "Queue",
|
||||||
@@ -766,6 +762,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Checking...",
|
"settings.updates.action.checking": "Checking...",
|
||||||
"settings.updates.toast.latest.title": "You're up to date",
|
"settings.updates.toast.latest.title": "You're up to date",
|
||||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "None",
|
"sound.option.none": "None",
|
||||||
"sound.option.alert01": "Alert 01",
|
"sound.option.alert01": "Alert 01",
|
||||||
"sound.option.alert02": "Alert 02",
|
"sound.option.alert02": "Alert 02",
|
||||||
|
|||||||
@@ -639,10 +639,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
|
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||||
"settings.general.row.font.title": "Fuente de código",
|
"settings.general.row.font.title": "Fuente",
|
||||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
|
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
|
||||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
|
||||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||||
"settings.general.row.followup.description":
|
"settings.general.row.followup.description":
|
||||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||||
@@ -674,6 +672,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Buscando...",
|
"settings.updates.action.checking": "Buscando...",
|
||||||
"settings.updates.toast.latest.title": "Estás al día",
|
"settings.updates.toast.latest.title": "Estás al día",
|
||||||
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
|
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Ninguno",
|
"sound.option.none": "Ninguno",
|
||||||
"sound.option.alert01": "Alerta 01",
|
"sound.option.alert01": "Alerta 01",
|
||||||
"sound.option.alert02": "Alerta 02",
|
"sound.option.alert02": "Alerta 02",
|
||||||
|
|||||||
@@ -578,10 +578,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
|
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
|
||||||
"settings.general.row.theme.title": "Thème",
|
"settings.general.row.theme.title": "Thème",
|
||||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||||
"settings.general.row.font.title": "Police de code",
|
"settings.general.row.font.title": "Police",
|
||||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
|
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
|
||||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
|
||||||
"settings.general.row.followup.title": "Comportement de suivi",
|
"settings.general.row.followup.title": "Comportement de suivi",
|
||||||
"settings.general.row.followup.description":
|
"settings.general.row.followup.description":
|
||||||
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
||||||
@@ -610,6 +608,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Vérification...",
|
"settings.updates.action.checking": "Vérification...",
|
||||||
"settings.updates.toast.latest.title": "Vous êtes à jour",
|
"settings.updates.toast.latest.title": "Vous êtes à jour",
|
||||||
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
|
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Aucun",
|
"sound.option.none": "Aucun",
|
||||||
"sound.option.alert01": "Alerte 01",
|
"sound.option.alert01": "Alerte 01",
|
||||||
"sound.option.alert02": "Alerte 02",
|
"sound.option.alert02": "Alerte 02",
|
||||||
|
|||||||
@@ -568,10 +568,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
|
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
|
||||||
"settings.general.row.theme.title": "テーマ",
|
"settings.general.row.theme.title": "テーマ",
|
||||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||||
"settings.general.row.font.title": "コードフォント",
|
"settings.general.row.font.title": "フォント",
|
||||||
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
|
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||||
"settings.general.row.uiFont.title": "UIフォント",
|
|
||||||
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
|
||||||
"settings.general.row.followup.title": "フォローアップの動作",
|
"settings.general.row.followup.title": "フォローアップの動作",
|
||||||
"settings.general.row.followup.description":
|
"settings.general.row.followup.description":
|
||||||
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
|
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
|
||||||
@@ -599,6 +597,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "確認中...",
|
"settings.updates.action.checking": "確認中...",
|
||||||
"settings.updates.toast.latest.title": "最新です",
|
"settings.updates.toast.latest.title": "最新です",
|
||||||
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
|
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "なし",
|
"sound.option.none": "なし",
|
||||||
"sound.option.alert01": "アラート 01",
|
"sound.option.alert01": "アラート 01",
|
||||||
"sound.option.alert02": "アラート 02",
|
"sound.option.alert02": "アラート 02",
|
||||||
|
|||||||
@@ -569,10 +569,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
|
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
|
||||||
"settings.general.row.theme.title": "테마",
|
"settings.general.row.theme.title": "테마",
|
||||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||||
"settings.general.row.font.title": "코드 글꼴",
|
"settings.general.row.font.title": "글꼴",
|
||||||
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
|
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||||
"settings.general.row.uiFont.title": "UI 글꼴",
|
|
||||||
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
|
||||||
"settings.general.row.followup.title": "후속 조치 동작",
|
"settings.general.row.followup.title": "후속 조치 동작",
|
||||||
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
|
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
|
||||||
"settings.general.row.followup.option.queue": "대기열",
|
"settings.general.row.followup.option.queue": "대기열",
|
||||||
@@ -599,6 +597,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "확인 중...",
|
"settings.updates.action.checking": "확인 중...",
|
||||||
"settings.updates.toast.latest.title": "최신 상태입니다",
|
"settings.updates.toast.latest.title": "최신 상태입니다",
|
||||||
"settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
|
"settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "없음",
|
"sound.option.none": "없음",
|
||||||
"sound.option.alert01": "알림 01",
|
"sound.option.alert01": "알림 01",
|
||||||
"sound.option.alert02": "알림 02",
|
"sound.option.alert02": "알림 02",
|
||||||
|
|||||||
@@ -639,10 +639,8 @@ export const dict = {
|
|||||||
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
|
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
|
||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
||||||
"settings.general.row.font.title": "Kodefont",
|
"settings.general.row.font.title": "Skrift",
|
||||||
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
|
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||||
"settings.general.row.uiFont.title": "UI-skrift",
|
|
||||||
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
|
||||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||||
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
|
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
|
||||||
"settings.general.row.followup.option.queue": "Kø",
|
"settings.general.row.followup.option.queue": "Kø",
|
||||||
@@ -670,6 +668,19 @@ export const dict = {
|
|||||||
"settings.updates.action.checking": "Sjekker...",
|
"settings.updates.action.checking": "Sjekker...",
|
||||||
"settings.updates.toast.latest.title": "Du er oppdatert",
|
"settings.updates.toast.latest.title": "Du er oppdatert",
|
||||||
"settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
|
"settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
|
||||||
|
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||||
|
"font.option.cascadiaCode": "Cascadia Code",
|
||||||
|
"font.option.firaCode": "Fira Code",
|
||||||
|
"font.option.hack": "Hack",
|
||||||
|
"font.option.inconsolata": "Inconsolata",
|
||||||
|
"font.option.intelOneMono": "Intel One Mono",
|
||||||
|
"font.option.iosevka": "Iosevka",
|
||||||
|
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||||
|
"font.option.mesloLgs": "Meslo LGS",
|
||||||
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.none": "Ingen",
|
"sound.option.none": "Ingen",
|
||||||
"sound.option.alert01": "Varsel 01",
|
"sound.option.alert01": "Varsel 01",
|
||||||
"sound.option.alert02": "Varsel 02",
|
"sound.option.alert02": "Varsel 02",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user