Compare commits

..
Author SHA1 Message Date
Dax Raad 6c80e2662c core: make server runtime-agnostic by migrating from Bun to Node.js HTTP/WebSocket APIs
This enables running the opencode server on standard Node.js runtimes without requiring Bun-specific APIs. Users can now deploy the server in more environments including standard Node.js containers and cloud platforms that don't support Bun.
2026-03-09 16:36:39 -04:00
1146 changed files with 70029 additions and 148877 deletions
-8
View File
@@ -10,10 +10,6 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth
-danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
fwang fwang
@@ -21,11 +17,7 @@ iamdavidhill
jayair jayair
kitlangton kitlangton
kommander kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot
+9 -26
View File
@@ -3,6 +3,14 @@ description: "Setup Bun with caching and install dependencies"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Get baseline download URL - name: Get baseline download URL
id: bun-url id: bun-url
shell: bash shell: bash
@@ -23,31 +31,6 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }} bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies - name: Install dependencies
run: | run: bun install
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash shell: bash
-24
View File
@@ -1,24 +0,0 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
+2 -3
View File
@@ -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"
+1 -5
View File
@@ -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.
@@ -60,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability # Extract hash from build log with portability
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}" echo "::error::Failed to compute hash for ${SYSTEM}"
+1 -194
View File
@@ -98,129 +98,15 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: opencode-cli name: opencode-cli
path: | path: packages/opencode/dist
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
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:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
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: |
$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"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
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: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
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:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
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 - build-cli
- version - version
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:
@@ -229,9 +115,6 @@ jobs:
target: x86_64-apple-darwin target: x86_64-apple-darwin
- host: macos-latest - host: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025 - host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404 - host: blacksmith-4vcpu-ubuntu-2404
@@ -266,18 +149,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
with:
node-version: "24"
- name: Cache apt packages - name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4 uses: actions/cache@v4
@@ -312,7 +183,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 }}
@@ -369,34 +239,11 @@ 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 - build-cli
- version - version
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:
@@ -407,10 +254,6 @@ jobs:
- host: macos-latest - host: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
platform_flag: --mac --arm64 platform_flag: --mac --arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
- host: "blacksmith-4vcpu-windows-2025" - host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
platform_flag: --win platform_flag: --win
@@ -438,14 +281,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"
@@ -480,7 +315,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 }}
@@ -513,22 +347,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,7 +362,6 @@ jobs:
needs: needs:
- version - version
- build-cli - build-cli
- sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -583,16 +400,6 @@ jobs:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release if: needs.version.outputs.release
with: with:
+54
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+20 -34
View File
@@ -6,16 +6,6 @@ on:
- dev - dev
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
# Keep every run on dev so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -50,17 +40,20 @@ jobs:
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
settings: settings:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows - name: windows
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env: env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers PLAYWRIGHT_BROWSERS_PATH: 0
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -73,36 +66,14 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app working-directory: packages/app
run: bunx playwright install chromium run: ${{ matrix.settings.playwright }}
- name: Run app e2e tests - name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
@@ -115,3 +86,18 @@ jobs:
path: | path: |
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"
+1 -1
View File
@@ -33,6 +33,6 @@ jobs:
with: with:
issue-id: ${{ github.event.issue.number }} issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write roles: admin,maintain
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+1 -2
View File
@@ -17,7 +17,7 @@ ts-dist
/result /result
refs refs
Session.vim Session.vim
/opencode.json opencode.json
a.out a.out
target target
.scripts .scripts
@@ -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
+2 -5
View File
@@ -1,6 +1,3 @@
node_modules plans/
plans
package.json
bun.lock bun.lock
.gitignore package.json
package-lock.json
+34
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
--- ---
description: Translate content for a specified locale while preserving technical terms description: Translate content for a specified locale while preserving technical terms
mode: subagent mode: subagent
model: opencode/gpt-5.4 model: opencode/gemini-3-pro
--- ---
You are a professional translator and localization specialist. You are a professional translator and localization specialist.
-46
View File
@@ -1,46 +0,0 @@
---
model: opencode/gpt-5.4
---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
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.
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
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:
- Write the final file with sections in this order:
`## 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**
<changelog_input>
!`bun script/raw-changelog.ts $ARGUMENTS`
</changelog_input>
-223
View File
@@ -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"
}
}
}
-891
View File
@@ -1,891 +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
hint?: JSX.Element
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
]
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} placeholders={{ normal, shell }} />
},
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
View File
@@ -1 +0,0 @@
smoke-theme.json
+3 -10
View File
@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
...options, ...options,
@@ -22,16 +24,7 @@ interface PR {
} }
export default tool({ export default tool({
description: `Use this tool to search GitHub pull requests by title and description. description: DESCRIPTION,
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
+10
View File
@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+16 -13
View File
@@ -1,10 +1,20 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"], tui: [
core: ["thdxr", "rekram1-node", "jlongster"], "thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"], docs: ["R44VC0RP"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
@@ -38,17 +48,9 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: `Use this tool to assign and/or label a GitHub issue. description: DESCRIPTION,
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue") .describe("The labels(s) to add to the issue")
@@ -71,7 +73,8 @@ If unsure, choose the team/section with the most overlap with the issue and assi
results.push("Dropped label: nix (issue does not mention nix)") results.push("Dropped label: nix (issue does not mention nix)")
} }
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee // const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) { if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'") throw new Error("Only add the zen label when issue title/body contains 'zen'")
+8
View File
@@ -0,0 +1,8 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)
-18
View File
@@ -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"
-4
View File
@@ -122,7 +122,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
--- ---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) **加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
--- ---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) **加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+936 -779
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1773909469, "lastModified": 1772091128,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=", "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b", "rev": "3f0336406035444b4a24b942788334af5f906259",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1
View File
@@ -496,6 +496,7 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"], todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"], bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"], edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"], glob: ["Glob", "\x1b[34m\x1b[1m"],
-15
View File
@@ -103,12 +103,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
const zenLiteProduct = new stripe.Product("ZenLite", { const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go", name: "OpenCode Go",
}) })
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
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",
@@ -122,8 +116,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })
@@ -202,10 +194,6 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", { const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts", handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")], link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -224,9 +212,6 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_LIMITS"),
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=", "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=", "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=", "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk=" "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
} }
} }
+1 -2
View File
@@ -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
-4
View File
@@ -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 -13
View File
@@ -4,12 +4,11 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.11", "packageManager": "bun@1.3.10",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
@@ -25,8 +24,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.43", "@types/bun": "1.3.9",
"@types/bun": "1.3.11",
"@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",
@@ -43,17 +41,15 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.43", "ai": "5.0.124",
"ai": "6.0.138",
"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",
@@ -104,7 +100,6 @@
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter", "web-tree-sitter",
"electron" "electron"
], ],
@@ -114,8 +109,6 @@
}, },
"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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
} }
} }
-38
View File
@@ -70,8 +70,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog - `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog - `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `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
- `withSession(sdk, title, callback)` - Create temp session - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace - `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL - `sessionIDFromUrl(url)` - Read session ID from URL
@@ -169,42 +167,6 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings await page.keyboard.press(`${modKey}+Comma`) // Open settings
``` ```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests ## Writing New Tests
1. Choose appropriate folder or create new one 1. Choose appropriate folder or create new one
+19 -341
View File
@@ -1,15 +1,12 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
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, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector, projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
@@ -18,22 +15,10 @@ import {
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
promptSelector,
terminalSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
.evaluate(() => { .evaluate(() => {
@@ -43,141 +28,9 @@ export async function defocus(page: Page) {
.catch(() => undefined) .catch(() => undefined)
} }
async function terminalID(term: Locator) { export async function openPalette(page: Page) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+${key}`) await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@@ -207,49 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first() await expect(button).toBeVisible()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 }) return (await button.getAttribute("aria-expanded")) !== "true"
await assertHealthy(page, context)
return button
} }
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
@@ -260,7 +73,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) { export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = await waitSidebarButton(page, "openSidebar") const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() await button.click()
const opened = await expect(button) const opened = await expect(button)
@@ -277,7 +90,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = await waitSidebarButton(page, "closeSidebar") const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() await button.click()
const closed = await expect(button) const closed = await expect(button)
@@ -292,7 +105,6 @@ export async function closeSidebar(page: Page) {
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page) await defocus(page)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -305,18 +117,15 @@ export async function openSettings(page: Page) {
if (opened) return dialog if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click() await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
return dialog return dialog
} }
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) { export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript( await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => { (args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server" const key = "opencode.global.dat:server"
const defaultKey = "opencode.settings.dat:defaultServerUrl"
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
const parsed = (() => { const parsed = (() => {
if (!raw) return undefined if (!raw) return undefined
@@ -332,7 +141,6 @@ export async function seedProjects(page: Page, input: { directory: string; extra
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {} const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) } const nextProjects = { ...(projects as Record<string, unknown>) }
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => { const add = (origin: string, directory: string) => {
const current = nextProjects[origin] const current = nextProjects[origin]
@@ -358,25 +166,22 @@ export async function seedProjects(page: Page, input: { directory: string; extra
localStorage.setItem( localStorage.setItem(
key, key,
JSON.stringify({ JSON.stringify({
list: nextList, list,
projects: nextProjects, projects: nextProjects,
lastProject, lastProject,
}), }),
) )
localStorage.setItem(defaultKey, args.serverUrl)
}, },
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] }, { directory: input.directory, serverUrl, extra: input.extra ?? [] },
) )
} }
export async function createTestProject(input?: { serverUrl?: string }) { 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)}`
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`) await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
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 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', {
@@ -384,7 +189,7 @@ export async function createTestProject(input?: { serverUrl?: string }) {
stdio: "ignore", stdio: "ignore",
}) })
return resolveDirectory(root, input?.serverUrl) return resolveDirectory(root)
} }
export async function cleanupTestProject(directory: string) { export async function cleanupTestProject(directory: string) {
@@ -398,24 +203,12 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
} }
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) { export async function waitSlug(page: Page, skip: string[] = []) {
let prev = "" let prev = ""
let next = "" let next = ""
await expect await expect
.poll( .poll(
async () => { () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
if (skip.includes(slug)) return "" if (skip.includes(slug)) return ""
@@ -433,97 +226,6 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory, input?.serverUrl)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
const target = await resolveDirectory(directory, input?.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug, input)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && current) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
const sdk = createSdk(directory, serverUrl)
const target = await resolveDirectory(directory, serverUrl)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -669,9 +371,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)
@@ -838,19 +539,12 @@ export async function seedSessionTask(
.flatMap((message) => message.parts) .flatMap((message) => message.parts)
.find((part) => { .find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false if (part.type !== "tool" || part.tool !== "task") return false
if (!("state" in part) || !part.state || typeof part.state !== "object") return false if (part.state.input?.description !== input.description) return false
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
}) })
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return if (!part) return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const id = part.state.metadata?.sessionId
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (typeof id !== "string" || !id) return if (typeof id !== "string" || !id) return
const child = await sdk.session const child = await sdk.session
.get({ sessionID: id }) .get({ sessionID: id })
@@ -936,14 +630,8 @@ export async function openStatusPopover(page: Page) {
} }
export async function openProjectMenu(page: Page, projectSlug: string) { export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -952,7 +640,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger const clicked = await trigger
.click({ force: true, timeout: 1500 }) .click({ timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
@@ -1023,13 +711,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")
}
-3
View File
@@ -3,11 +3,8 @@ import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => { test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/") await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
}) })
+1 -10
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { closeDialog, openPalette } from "../actions" import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => { test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -9,12 +9,3 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
}) })
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
-125
View File
@@ -1,125 +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})` : ""}`)
}
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): 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_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
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",
} 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 (proc.exitCode === null) {
proc.kill("SIGTERM")
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
}
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
},
}
}
+50 -231
View File
@@ -1,165 +1,31 @@
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 { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import type { E2EWindow } from "../src/testing/terminal" import { promptSelector } from "./selectors"
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
import { startBackend } from "./backend"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, 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>
}
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
const seedModel = (() => {
const [providerID = "opencode", modelID = "big-pickle"] = (
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
).split("/")
return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type ProjectHandle = {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
sdk: ReturnType<typeof createSdk>
}
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 TestFixtures = { type TestFixtures = {
llm: LLMFixture
sdk: ReturnType<typeof createSdk> sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> withProject: <T>(
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
} }
type WorkerFixtures = { type WorkerFixtures = {
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>({
backend: [
async ({}, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`)
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
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)),
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),
})
} finally {
await rt.dispose()
}
},
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [ directory: [
async ({}, use) => { async ({}, use) => {
const directory = await getWorktree() const directory = await getWorktree()
@@ -181,106 +47,59 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID }) await expect(page.locator(promptSelector)).toBeVisible()
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use((callback, options) => runProject(page, callback, options)) await use(async (callback, options) => {
}, const root = await createTestProject()
withBackendProject: async ({ page, backend }, use) => { const slug = dirSlug(root)
await use((callback, options) => const sessions = new Map<string, string>()
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }), const dirs = new Set<string>()
) await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
}
})
}, },
}) })
async function runProject<T>( async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
page: Page,
callback: (project: ProjectHandle) => Promise<T>,
options?: ProjectOptions & {
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
) {
const url = options?.serverUrl
const root = await createTestProject(url ? { serverUrl: url } : undefined)
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await options?.setup?.(root)
await seedStorage(page, {
directory: root,
extra: options?.extra,
model: options?.model,
serverUrl: url,
})
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID, serverUrl: url })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await options?.beforeGoto?.({ directory: root, sdk })
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
}
async function seedStorage(
page: Page,
input: {
directory: string
extra?: string[]
model?: { providerID: string; modelID: string }
serverUrl?: string
},
) {
await seedProjects(page, input) await seedProjects(page, input)
await page.addInitScript((model: { providerID: string; modelID: string }) => { await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:model", "opencode.global.dat:model",
JSON.stringify({ JSON.stringify({
recent: [model], recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [], user: [],
variant: {}, variant: {},
}), }),
) )
}, input.model ?? seedModel) })
} }
export { expect } export { expect }
@@ -1,19 +1,41 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -54,7 +76,9 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await setWorkspacesEnabled(page, slug, true) await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
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()
@@ -76,7 +100,11 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
await waitSession(page, { directory: space }) // A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -90,7 +118,6 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space) 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}(?:[/?#]|$)`))
@@ -98,14 +125,14 @@ test("switching back to a project opens the latest workspace session", async ({
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({ force: true }) await otherButton.click()
await waitSession(page, { directory: other }) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(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()
await waitSession(page, { directory: space, sessionID: created }) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
@@ -1,94 +1,109 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) { async function waitWorkspaceReady(page: Page, slug: string) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 }) await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
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, [root, ...seen])) const slug = await waitSlug(page, [root, ...seen])
await waitDir(page, next.directory) const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next return next
} }
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await waitWorkspaceReady(page, space) const next = await openWorkspaceNewSession(page, slug)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text) await prompt.fill(text)
await page.keyboard.press("Enter") await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
await waitSessionSaved(space.directory, sessionID) async function sessionDirectory(directory: string, sessionID: string) {
await createSdk(space.directory) const info = await createSdk(directory)
.session.abort({ sessionID }) .session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
return sessionID if (!info) return ""
return info.directory
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { 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 withProject(async ({ slug: root, trackDirectory, trackSession }) => { await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, []) const first = await createWorkspace(page, root, [])
trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first) await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second.slug)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) trackSession(firstSession.sessionID, first.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
}) })
}) })
+14 -16
View File
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
@@ -13,10 +13,8 @@ import {
confirmDialog, confirmDialog,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled, setWorkspacesEnabled,
slugFromUrl, slugFromUrl,
waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -29,15 +27,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
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 slug = await waitSlug(page, [rootSlug])
await waitDir(page, next.directory) const dir = base64Decode(slug)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first() const item = page.locator(workspaceItemSelector(slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -49,7 +47,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
) )
.toBe(true) .toBe(true)
return { rootSlug, slug: next.slug, directory: next.directory } return { rootSlug, slug, directory: dir }
} }
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -81,15 +79,15 @@ test("can create a workspace", async ({ page, withProject }) => {
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, [slug])) const workspaceSlug = await waitSlug(page, [slug])
await waitDir(page, next.directory) const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first() const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -101,9 +99,9 @@ test("can create a workspace", async ({ page, withProject }) => {
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(next.directory) await cleanupTestProject(workspaceDir)
}) })
}) })
@@ -121,7 +119,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
@@ -333,9 +331,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
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 slug = await waitSlug(page, [rootSlug, prev])
await waitDir(page, next.directory) const dir = base64Decode(slug)
workspaces.push(next) workspaces.push({ slug, directory: dir })
await openSidebar(page) await openSidebar(page)
} }
-46
View File
@@ -1,46 +0,0 @@
import { createSdk } from "../utils"
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) {
return JSON.stringify(hit.body)
}
export function titleMatch(hit: Hit) {
return bodyText(hit).includes("Generate a title for this conversation")
}
export function promptMatch(token: string) {
return (hit: Hit) => bodyText(hit).includes(token)
}
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
try {
await sdk.global.config.update({
config: {
...prev,
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
enabled_providers: ["openai"],
provider: {
...prev.provider,
openai: {
...prev.provider?.openai,
options: {
...prev.provider?.openai?.options,
apiKey: "test-key",
baseURL: input.llmUrl,
},
},
},
},
})
return await input.fn()
} finally {
await sdk.global.config.update({ config: prev })
}
}
+26 -31
View File
@@ -1,52 +1,47 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, sessionIDFromUrl, withSession } from "../actions" import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
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 ({ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
page,
llm,
backend,
withBackendProject,
}) => {
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"))
await withMockOpenAI({ await gotoSession()
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await withBackendProject( const token = `E2E_ASYNC_${Date.now()}`
async (project) => { await page.locator(promptSelector).click()
await page.locator(promptSelector).click() await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.type(`Reply with exactly: ${token}`) await page.keyboard.press("Enter")
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())! const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1) try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) await expect
}, .poll(
{ async () => {
model: openaiModel, 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 }) => {
+87 -123
View File
@@ -1,9 +1,8 @@
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, sessionIDFromUrl } from "../actions" import { withSession } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
@@ -44,13 +43,20 @@ 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( async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
sessionID: string,
token: string,
) {
await expect await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 }) .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) .toContain(token)
} }
@@ -73,145 +79,103 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
.toContain(token) .toContain(token)
} }
test("prompt history restores unsent draft with arrow navigation", async ({ test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withMockOpenAI({ await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
serverUrl: backend.url, await gotoSession(session.id)
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title") const prompt = page.locator(promptSelector)
await llm.textMatch(promptMatch(firstToken), firstToken) const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
await llm.textMatch(promptMatch(secondToken), secondToken) const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await withBackendProject( await prompt.click()
async (project) => { await page.keyboard.type(first)
const prompt = page.locator(promptSelector) await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type(first) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, session.id, secondToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await prompt.click()
const sessionID = sessionIDFromUrl(page.url())! await page.keyboard.type(draft)
project.trackSession(sessionID) await wait(page, draft)
await reply(project.sdk, sessionID, firstToken)
await prompt.click() await edge(page, "start")
await page.keyboard.type(second) await page.keyboard.press("ArrowUp")
await page.keyboard.press("Enter") await wait(page, second)
await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await prompt.click() await page.keyboard.press("ArrowUp")
await page.keyboard.type(draft) await wait(page, first)
await wait(page, draft)
await prompt.fill("") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, second)
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowDown")
await wait(page, second) await wait(page, draft)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
},
{
model: openaiModel,
},
)
},
}) })
}) })
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => { test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withMockOpenAI({ await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
serverUrl: backend.url, await gotoSession(session.id)
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await llm.textMatch(titleMatch, "E2E Title") const prompt = page.locator(promptSelector)
await llm.textMatch(promptMatch(normalToken), normalToken) const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await withBackendProject( await prompt.click()
async (project) => { await page.keyboard.type("!")
const prompt = page.locator(promptSelector) await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(first) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await prompt.click()
const sessionID = sessionIDFromUrl(page.url())! await page.keyboard.type("!")
project.trackSession(sessionID) await page.keyboard.press("ArrowUp")
await shell(project.sdk, sessionID, first, firstToken) await wait(page, second)
await prompt.click() await page.keyboard.press("ArrowUp")
await page.keyboard.type("!") await wait(page, first)
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(project.sdk, sessionID, second, secondToken)
await prompt.click() await page.keyboard.press("ArrowDown")
await page.keyboard.type("!") await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowDown")
await wait(page, first) await wait(page, "")
await page.keyboard.press("ArrowDown") await page.keyboard.press("Escape")
await wait(page, second) await wait(page, "")
await page.keyboard.press("ArrowDown") await prompt.click()
await wait(page, "") await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await page.keyboard.press("Escape") await prompt.click()
await wait(page, "") await page.keyboard.press("ArrowUp")
await wait(page, normal)
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
},
{
model: openaiModel,
},
)
},
}) })
}) })
@@ -7,18 +7,12 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await prompt.focus() await prompt.click()
await expect(prompt).toBeFocused() await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await prompt.pressSequentially("line one") await page.keyboard.type("line two")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two") await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
}) })
+5 -3
View File
@@ -2,6 +2,7 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions" import { sessionIDFromUrl } from "../actions"
import { promptSelector } 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,12 +11,13 @@ 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, withBackendProject }) => { test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => { await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "command ls" const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession() await gotoSession()
await prompt.click() await prompt.click()
@@ -22,45 +22,43 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { 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")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
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 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await prompt.click() await prompt.click()
await page.keyboard.type("/unshare") await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
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 },
) )
.toBeUndefined() .toBeUndefined()
})
}) })
}) })
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -10,9 +9,15 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) await prompt.click()
await waitTerminalFocusIdle(page, { term: terminal }) await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
}) })
+34 -37
View File
@@ -1,9 +1,8 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, sessionIDFromUrl } from "../actions" import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => { 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[] = []
@@ -12,44 +11,42 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
} }
page.on("pageerror", onPageError) page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
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 { try {
await withMockOpenAI({ await expect
serverUrl: backend.url, .poll(
llmUrl: llm.url, async () => {
fn: async () => { const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const token = `E2E_OK_${Date.now()}` 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 },
)
await llm.textMatch(titleMatch, "E2E Title") .toContain(token)
await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
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
})()
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
} }
if (pageErrors.length > 0) { if (pageErrors.length > 0) {
+2 -7
View File
@@ -1,6 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]' export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]' export const terminalSelector = '[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"]'
@@ -13,14 +12,10 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]' 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 promptModelSelector = '[data-component="prompt-model-control"]'
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"]'
@@ -1,7 +1,7 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => { 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[] = []
@@ -10,32 +10,28 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { const child = await seedSessionTask(sdk, {
trackSession(session.id) sessionID: session.id,
const child = await seedSessionTask(sdk, { description: "Open child session",
sessionID: session.id, prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
trackSession(child.sessionID)
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
}) })
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
}) })
}) })
@@ -1,19 +1,14 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
questionDockSelector, questionDockSelector,
sessionComposerDockSelector, sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
import { modKey } from "../utils"
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" }
@@ -47,8 +42,12 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
async function clearPermissionDock(page: any, label: RegExp) { async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector) const dock = page.locator(permissionDockSelector)
await expect(dock).toBeVisible() for (let i = 0; i < 3; i++) {
await dock.getByRole("button", { name: label }).click() const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
} }
async function setAutoAccept(page: any, enabled: boolean) { async function setAutoAccept(page: any, enabled: boolean) {
@@ -60,120 +59,6 @@ async function setAutoAccept(page: any, enabled: boolean) {
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
} }
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID: string) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>( async function withMockPermission<T>(
page: any, page: any,
request: { request: {
@@ -185,10 +70,8 @@ async function withMockPermission<T>(
always?: string[] always?: string[]
}, },
opts: { child?: any } | undefined, opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>, fn: () => Promise<T>,
) { ) {
const listUrl = /\/permission(?:\?.*)?$/
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
let pending = [ let pending = [
{ {
...request, ...request,
@@ -207,8 +90,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,
@@ -217,10 +99,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) => {
@@ -239,389 +119,307 @@ async function withMockPermission<T>(
if (sessionList) await page.route("**/session?*", sessionList) if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try { try {
return await fn(state) return await fn()
} 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, withBackendProject }) => { test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock default", async (session) => {
await withDockSession(project.sdk, "e2e composer dock default", async (session) => { await gotoSession(session.id)
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(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
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, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
}) })
}) })
}) })
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withBackendProject(async ({ gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession() await gotoSession(session.id)
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, false) await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
}) })
}) })
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => { test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSession(project.sdk, "e2e composer dock question", async (session) => { await gotoSession(session.id)
await withDockSeed(project.sdk, session.id, async () => { await setAutoAccept(page, false)
await project.gotoSession(session.id) await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await seedSessionQuestion(project.sdk, { await clearPermissionDock(page, /deny/i)
sessionID: session.id, await page.goto(page.url())
questions: [ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
{ await expect(page.locator(promptSelector)).toBeVisible()
header: "Need input", },
question: "Pick one option", )
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
})
}) })
}) })
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => { await gotoSession(session.id)
await withDockSeed(project.sdk, session.id, async () => { await setAutoAccept(page, false)
await project.gotoSession(session.id) await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await seedSessionQuestion(project.sdk, { await clearPermissionDock(page, /allow always/i)
sessionID: session.id, await page.goto(page.url())
questions: [ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
{ await expect(page.locator(promptSelector)).toBeVisible()
header: "Need input", },
question: "Pick one option", )
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
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)
})
})
})
})
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
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 first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
})
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
}) })
}) })
test("child session question request blocks parent dock and unblocks after submit", async ({ test("child session question request blocks parent dock and unblocks after submit", async ({
page, page,
withBackendProject, sdk,
gotoSession,
}) => { }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => { await gotoSession(session.id)
await project.gotoSession(session.id)
const child = await project.sdk.session const child = await sdk.session
.create({ .create({
title: "e2e composer dock child question", title: "e2e composer dock child question",
parentID: session.id, 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",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
}) })
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try { const dock = page.locator(questionDockSelector)
await withDockSeed(project.sdk, child.id, async () => { await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await seedSessionQuestion(project.sdk, { await expect(page.locator(promptSelector)).toHaveCount(0)
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
const dock = page.locator(questionDockSelector) await dock.locator('[data-slot="question-option"]').first().click()
await expectQuestionBlocked(page) await dock.getByRole("button", { name: /submit/i }).click()
await dock.locator('[data-slot="question-option"]').first().click() await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await dock.getByRole("button", { name: /submit/i }).click() await expect(page.locator(promptSelector)).toBeVisible()
})
await expectQuestionOpen(page) } finally {
}) await cleanupSession({ sdk, sessionID: child.id })
} finally { }
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
})
}) })
}) })
test("child session permission request blocks parent dock and supports allow once", async ({ test("child session permission request blocks parent dock and supports allow once", async ({
page, page,
withBackendProject, sdk,
gotoSession,
}) => { }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => { await gotoSession(session.id)
await project.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")
try { try {
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_child", id: "per_e2e_child",
sessionID: child.id, sessionID: child.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"], patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" }, metadata: { description: "Need child permission" },
}, },
{ child }, { child },
async (state) => { async () => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i) await clearPermissionDock(page, /allow once/i)
await state.resolved() await page.goto(page.url())
await page.goto(page.url())
await expectPermissionOpen(page) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
}, await expect(page.locator(promptSelector)).toBeVisible()
) },
} finally { )
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) } finally {
} await cleanupSession({ sdk, sessionID: child.id })
}) }
}) })
}) })
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withBackendProject(async (project) => { await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => { await withDockSeed(sdk, session.id, async () => {
const dock = await todoDock(page, session.id) await gotoSession(session.id)
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try { await seedSessionTodos(sdk, {
await dock.open([ sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" }, { content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" }, { content: "second task", status: "in_progress", priority: "medium" },
]) ],
await dock.expectOpen(["pending", "in_progress"]) })
await dock.collapse() await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await dock.expectCollapsed(["pending", "in_progress"]) await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await dock.expand() await page.locator(sessionTodoToggleButtonSelector).click()
await dock.expectOpen(["pending", "in_progress"]) await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await dock.finish([ await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" }, { content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" }, { content: "second task", status: "cancelled", priority: "medium" },
]) ],
await dock.expectClosed()
} finally {
await dock.clear()
}
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
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",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
}) })
}) })
@@ -1,405 +0,0 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
agent?: string
model?: { providerID: string; modelID: string; name?: string }
variant?: string | null
pick?: {
agent?: 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 text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
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 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> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await pickAgent(page, value)
}
async function variantCount(page: Page) {
return (await probe(page))?.variants?.length ?? 0
}
async function agents(page: Page) {
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
if (!next) throw new Error("Current model has no alternate variant to select")
await pickVariant(page, next)
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
const current = await currentModel(page)
const next = (await probe(page))?.models?.find((item) => {
const key = `${item.providerID}:${item.modelID}`
return key !== current && !skip.includes(key)
})
if (!next) throw new Error("Failed to choose a different model")
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
return read(page)
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
}
async function submit(page: Page, value: string) {
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[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitSession(page, { directory: next.directory })
return next
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()
const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
@@ -1,428 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
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)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})
@@ -49,13 +49,13 @@ async function seedConversation(input: {
return { prompt, userMessageID } return { prompt, userMessageID }
} }
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { 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) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
@@ -81,13 +81,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
}) })
}) })
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { 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) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
@@ -128,14 +128,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
}) })
}) })
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { 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) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
+99 -107
View File
@@ -31,152 +31,144 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("session can be renamed via header menu", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { await withSession(sdk, originalTitle, async (session) => {
await withSession(project.sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id)
await seedMessage(project.sdk, session.id) await gotoSession(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)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle) await expect(input).toHaveValue(renamedTitle)
await input.press("Enter") await input.press("Enter")
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 },
) )
.toBe(renamedTitle) .toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
}) })
}) })
test("session can be archived via header menu", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
await seedMessage(project.sdk, session.id) await gotoSession(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 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
}) })
}) })
test("session can be deleted via header menu", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
await seedMessage(project.sdk, session.id) await gotoSession(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)
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)
return data?.id return data?.id
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
}) })
}) })
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => { 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 withBackendProject(async (project) => { await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
await seedMessage(project.sdk, session.id) await gotoSession(session.id)
await project.gotoSession(session.id)
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()
await expect(publish).toBeVisible({ timeout: 30_000 }) await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click() await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
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 },
) )
.not.toBeUndefined() .not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
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 },
) )
.toBeUndefined() .toBeUndefined()
const unshared = await openSharePopover(page) const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
})
}) })
}) })
}) })
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions" import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible() await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent() const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("K") expect(initialKeybind).toContain("P")
await keybindButton.click() await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i) await expect(keybindButton).toHaveText(/press/i)
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await waitTerminalFocusIdle(page, { term: terminal }) await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
+55 -244
View File
@@ -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"
@@ -153,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 raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono: next,
},
})
const newFontFamily = await page.evaluate(() => const stored = await page.evaluate((key) => {
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(), const raw = localStorage.getItem(key)
) return raw ? JSON.parse(raw) : null
const newUIFamily = await page.evaluate(() => }, settingsKey)
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
) expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
expect(newFontFamily).toContain(next)
const newFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
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)
@@ -356,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 () => {
@@ -395,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,
}, },
}) })
@@ -405,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()
@@ -432,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,
}, },
}) })
@@ -444,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) .not.toBe(initialFontFamily)
await expect const rehydratedFontFamily = await page.evaluate(() => {
.poll(async () => { return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
return await page.evaluate(() => })
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(), expect(rehydratedFontFamily).not.toBe(initialFontFamily)
) expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
})
.toContain(sans)
const rehydratedMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const rehydratedSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
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 }) => {
@@ -1,16 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
@@ -47,72 +37,3 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id }) await cleanupSession({ sdk, sessionID: two.id })
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,7 +13,8 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
} }
await waitTerminalFocusIdle(page, { term: terminals.first() }) await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back // Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() }) await expect(terminals.first().locator("textarea")).toHaveCount(1)
}) })
@@ -1,46 +0,0 @@
import type { Page } from "@playwright/test"
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
async function open(page: Page) {
const term = page.locator(terminalSelector).first()
const visible = await term.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term })
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession()
const term = await open(page)
const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term })
await runTerminal(page, {
term,
cmd: `export ${name}=${token}; echo ${token}`,
token,
})
await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, {
term,
cmd: `echo $${name}`,
token,
timeout: 15_000,
})
})
})
+15 -44
View File
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { dropdownMenuContentSelector, terminalSelector } from "../selectors" import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils" import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = { type State = {
@@ -18,7 +17,16 @@ async function open(page: Page) {
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false) const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey) if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal }) await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
} }
async function store(page: Page, key: string) { async function store(page: Page, key: string) {
@@ -48,16 +56,15 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
await gotoSession() await gotoSession()
await open(page) await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one }) await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two }) await run(page, `echo ${two}`)
await first.click() await first.click()
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -69,7 +76,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 30_000 },
) )
.toEqual({ first: false, second: true }) .toEqual({ first: false, second: true })
@@ -86,7 +93,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 30_000 },
) )
.toEqual({ first: true, second: false }) .toEqual({ first: true, second: false })
}) })
@@ -130,39 +137,3 @@ test("closing the active terminal tab falls back to the previous tab", async ({
.toEqual({ count: 1, first: true }) .toEqual({ count: 1, first: true })
}) })
}) })
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors" import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,5 +13,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
} }
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal }) await expect(terminal).toBeVisible()
}) })
+1 -2
View File
@@ -2,8 +2,7 @@
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"rootDir": "..",
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": ["./**/*.ts", "../src/testing/terminal.ts"] "include": ["./**/*.ts"]
} }
+7 -7
View File
@@ -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
} }
+1 -1
View File
@@ -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" />
+2 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.13", "version": "1.2.24",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -45,20 +45,17 @@
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@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/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@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/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
-2
View File
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" 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
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -18,7 +17,6 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: { webServer: {
command, command,
+1 -2
View File
@@ -71,9 +71,8 @@ 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",
} satisfies Record<string, string> } satisfies Record<string, string>
const runnerEnv = { const runnerEnv = {
+49 -190
View File
@@ -1,63 +1,50 @@
import "@/index.css" import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { type Duration, Effect } from "effect"
import {
type Component,
createMemo,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" 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 { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { Dynamic } from "solid-js/web"
const HomeRoute = lazy(() => import("@/pages/home")) const Home = 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)) { const HomeRoute = () => (
void loadSession() <Suspense fallback={<Loading />}>
} <Home />
</Suspense>
)
const SessionRoute = () => ( const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Session /> <Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders> </SessionProviders>
) )
@@ -65,7 +52,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
} }
declare global { declare global {
@@ -75,15 +62,12 @@ declare global {
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean wsl?: boolean
} }
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
} }
} }
function QueryProvider(props: ParentProps) { function MarkedProviderWithNativeParser(props: ParentProps) {
const client = new QueryClient() const platform = usePlatform()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
@@ -121,33 +105,25 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
<Suspense fallback={<Loading />}> {props.appChildren}
{props.appChildren} {props.children}
{props.children}
</Suspense>
</AppShellProviders> </AppShellProviders>
) )
} }
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
<ThemeProvider <ThemeProvider>
onThemeApplied={(_, mode) => { <LanguageProvider>
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider> <DialogProvider>
<DialogProvider> <MarkedProviderWithNativeParser>
<MarkedProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> </MarkedProviderWithNativeParser>
</MarkedProvider> </DialogProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -156,116 +132,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
) )
} }
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
@@ -280,32 +146,25 @@ export function AppInterface(props: {
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) { }) {
return ( return (
<ServerProvider <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
defaultServer={props.defaultServer} <ServerKey>
disableHealthCheck={props.disableHealthCheck} <GlobalSDKProvider>
servers={props.servers} <GlobalSyncProvider>
> <Dynamic
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> component={props.router ?? Router}
<ServerKey> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<GlobalSDKProvider> >
<GlobalSyncProvider> <Route path="/" component={HomeRoute} />
<Dynamic <Route path="/:dir" component={DirectoryLayout}>
component={props.router ?? Router} <Route path="/" component={SessionIndexRoute} />
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} <Route path="/session/:id?" component={SessionRoute} />
> </Route>
<Route path="/" component={HomeRoute} /> </Dynamic>
<Route path="/:dir" component={DirectoryLayout}> </GlobalSyncProvider>
<Route path="/" component={SessionIndexRoute} /> </GlobalSDKProvider>
<Route path="/session/:id?" component={SessionRoute} /> </ServerKey>
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider> </ServerProvider>
) )
} }
-443
View File
@@ -1,443 +0,0 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
type Mem = Performance & {
memory?: {
usedJSHeapSize: number
jsHeapSizeLimit: number
}
}
type Evt = PerformanceEntry & {
interactionId?: number
processingStart?: number
}
type Shift = PerformanceEntry & {
hadRecentInput: boolean
value: number
}
type Obs = PerformanceObserverInit & {
durationThreshold?: number
}
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
}
const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip>
)
}
export function DebugBar() {
const language = useLanguage()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
cls: undefined as number | undefined,
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
},
inp: undefined as number | undefined,
jank: undefined as number | undefined,
long: {
block: undefined as number | undefined,
count: undefined as number | undefined,
max: undefined as number | undefined,
},
nav: {
dur: undefined as number | undefined,
pending: false,
},
})
const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
if (value === undefined) return na()
return `${Math.round(value * 100)}%`
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
let prev = ""
let start = 0
let init = false
let one = 0
let two = 0
createEffect(() => {
const busy = routing()
const next = `${location.pathname}${location.search}`
if (!init) {
init = true
prev = next
return
}
if (busy) {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = 0
two = 0
if (start !== 0) return
start = performance.now()
if (session(prev)) setState("nav", { dur: undefined, pending: true })
return
}
if (start === 0) {
prev = next
return
}
const at = start
const from = prev
start = 0
prev = next
if (!(session(from) || session(next))) return
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = requestAnimationFrame(() => {
one = 0
two = requestAnimationFrame(() => {
two = 0
setState("nav", { dur: performance.now() - at, pending: false })
})
})
})
onMount(() => {
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
let hasLong = false
let poll: number | undefined
let raf = 0
let last = 0
let snap = 0
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
while (list[0] && at - list[0].at > span) list.shift()
}
const syncFrame = (at: number) => {
trim(fps, span, at)
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
const jank = fps.filter((entry) => entry.dur > 32).length
batch(() => {
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
setState("gap", gap > 0 ? gap : undefined)
setState("jank", jank)
})
}
const syncLong = (at = performance.now()) => {
if (!hasLong) return
trim(long, span, at)
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
setState("long", { block, count: long.length, max })
}
const syncInp = (at = performance.now()) => {
for (const [key, entry] of seen) {
if (at - entry.at > span) seen.delete(key)
}
let delay = 0
let inp = 0
for (const entry of seen.values()) {
delay = Math.max(delay, entry.delay)
inp = Math.max(inp, entry.dur)
}
batch(() => {
setState("delay", delay > 0 ? delay : undefined)
setState("inp", inp > 0 ? inp : undefined)
})
}
const syncHeap = () => {
const mem = (performance as Mem).memory
if (!mem) return
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
}
const reset = () => {
fps.length = 0
long.length = 0
seen.clear()
last = 0
snap = 0
batch(() => {
setState("fps", undefined)
setState("gap", undefined)
setState("jank", undefined)
setState("delay", undefined)
setState("inp", undefined)
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
})
}
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
if (typeof PerformanceObserver === "undefined") return false
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
try {
ob.observe(init)
obs.push(ob)
return true
} catch {
ob.disconnect()
return false
}
}
if (
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
const add = entries.reduce((sum, entry) => {
const item = entry as Shift
if (item.hadRecentInput) return sum
return sum + item.value
}, 0)
if (add === 0) return
setState("cls", (value) => (value ?? 0) + add)
})
) {
setState("cls", 0)
}
if (
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
const at = performance.now()
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
syncLong(at)
})
) {
hasLong = true
setState("long", { block: 0, count: 0, max: 0 })
}
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
for (const raw of entries) {
const entry = raw as Evt
if (entry.duration < 16) continue
const key =
entry.interactionId && entry.interactionId > 0
? entry.interactionId
: `${entry.name}:${Math.round(entry.startTime)}`
const prev = seen.get(key)
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
seen.set(key, {
at: entry.startTime,
delay: Math.max(prev?.delay ?? 0, delay),
dur: Math.max(prev?.dur ?? 0, entry.duration),
})
if (seen.size <= 200) continue
const first = seen.keys().next().value
if (first !== undefined) seen.delete(first)
}
syncInp()
})
const loop = (at: number) => {
if (document.visibilityState !== "visible") {
raf = 0
return
}
if (last === 0) {
last = at
raf = requestAnimationFrame(loop)
return
}
fps.push({ at, dur: at - last })
last = at
if (at - snap >= 250) {
snap = at
syncFrame(at)
}
raf = requestAnimationFrame(loop)
}
const stop = () => {
if (raf !== 0) cancelAnimationFrame(raf)
raf = 0
if (poll === undefined) return
clearInterval(poll)
poll = undefined
}
const start = () => {
if (document.visibilityState !== "visible") return
if (poll === undefined) {
poll = window.setInterval(() => {
syncLong()
syncInp()
syncHeap()
}, 1000)
}
if (raf !== 0) return
raf = requestAnimationFrame(loop)
}
const vis = () => {
if (document.visibilityState !== "visible") {
stop()
return
}
reset()
start()
}
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect()
})
})
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
wide
/>
</div>
</aside>
)
}
@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,26 +9,22 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
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 { usePlatform } from "@/context/platform"
import { useProviders } from "@/hooks/use-providers" import { DialogSelectModel } from "./dialog-select-model"
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 platform = usePlatform()
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,41 +36,26 @@ 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)!)
const methods = createMemo(
() => () =>
providers.all().find((x) => x.id === props.provider) ?? globalSync.data.provider_auth[props.provider] ?? [
globalSync.data.provider.all.find((x) => x.id === props.provider)!, {
type: "api",
label: language.t("provider.connect.method.apiKey"),
},
],
) )
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: "pending" as undefined | "pending" | "complete" | "error",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action = type Action =
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -96,11 +77,6 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -144,7 +120,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback return fallback
} }
async function selectMethod(index: number, inputs?: Record<string, string>) { async function selectMethod(index: number) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = undefined timer.current = undefined
@@ -154,10 +130,6 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
@@ -165,7 +137,6 @@ export function DialogConnectProvider(props: { provider: string }) {
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
inputs,
}, },
{ throwOnError: true }, { throwOnError: true },
) )
@@ -192,126 +163,6 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
} }
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -321,12 +172,8 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
let auto = false onMount(() => {
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) { if (methods().length === 1) {
auto = true
selectMethod(0) selectMethod(0)
} }
}) })
@@ -344,7 +191,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 +202,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() {
@@ -454,7 +301,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")} {language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -467,6 +314,12 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -515,7 +368,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")} {language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -533,6 +386,10 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
@@ -602,14 +459,6 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6"> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch> <Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}> <Match when={store.methodIndex === undefined}>
<MethodSelection /> <MethodSelection />
</Match> </Match>
@@ -621,9 +470,6 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
@@ -1,158 +0,0 @@
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
export type ModelErr = {
id?: string
name?: string
}
export type HeaderErr = {
key?: string
value?: string
}
export type ModelRow = {
row: string
id: string
name: string
err: ModelErr
}
export type HeaderRow = {
row: string
key: string
value: string
err: HeaderErr
}
export type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
err: {
providerID?: string
name?: string
baseURL?: string
}
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
export function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const models = input.form.models.map((m) => {
const id = m.id.trim()
const idError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
})
const modelsValid = models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headers = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headers.every((h) => !h.key && !h.value)
const headerConfig = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const err = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { err, models, headers }
return {
err,
models,
headers,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
}
}
let row = 0
const nextRow = () => `row-${row++}`
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
@@ -1,80 +0,0 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: " Custom Provider ",
baseURL: "https://api.example.com ",
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
headers: [
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
err: {},
},
t,
disabledProviders: [],
existingProviderIDs: new Set(),
})
expect(result.result).toEqual({
providerID: "custom-provider",
name: "Custom Provider",
key: undefined,
config: {
npm: "@ai-sdk/openai-compatible",
name: "Custom Provider",
env: ["CUSTOM_PROVIDER_KEY"],
options: {
baseURL: "https://api.example.com",
headers: {
"X-Test": "enabled",
},
},
models: {
"model-a": { name: "Model A" },
},
},
})
})
test("flags duplicate rows and allows reconnecting disabled providers", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: "Provider",
baseURL: "https://api.example.com",
apiKey: "secret",
models: [
{ row: "m0", id: "model-a", name: "Model A", err: {} },
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
],
headers: [
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
err: {},
},
t,
disabledProviders: ["custom-provider"],
existingProviderIDs: new Set(["custom-provider"]),
})
expect(result.result).toBeUndefined()
expect(result.err.providerID).toBeUndefined()
expect(result.models[1]).toEqual({
id: "provider.custom.error.duplicate",
name: undefined,
})
expect(result.headers[1]).toEqual({
key: "provider.custom.error.duplicate",
value: undefined,
})
})
})
@@ -3,18 +3,160 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { Link } from "@/components/link" 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 { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
} }
@@ -30,9 +172,17 @@ export function DialogCustomProvider(props: Props) {
name: "", name: "",
baseURL: "", baseURL: "",
apiKey: "", apiKey: "",
models: [modelRow()], models: [{ id: "", name: "" }],
headers: [headerRow()], headers: [{ key: "", value: "" }],
err: {}, saving: false,
})
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
}) })
const goBack = () => { const goBack = () => {
@@ -44,61 +194,25 @@ export function DialogCustomProvider(props: Props) {
} }
const addModel = () => { const addModel = () => {
setForm( setForm("models", (v) => [...v, { id: "", name: "" }])
"models", setErrors("models", (v) => [...v, {}])
produce((rows) => {
rows.push(modelRow())
}),
)
} }
const removeModel = (index: number) => { const removeModel = (index: number) => {
if (form.models.length <= 1) return if (form.models.length <= 1) return
setForm( setForm("models", (v) => v.filter((_, i) => i !== index))
"models", setErrors("models", (v) => v.filter((_, i) => i !== index))
produce((rows) => {
rows.splice(index, 1)
}),
)
} }
const addHeader = () => { const addHeader = () => {
setForm( setForm("headers", (v) => [...v, { key: "", value: "" }])
"headers", setErrors("headers", (v) => [...v, {}])
produce((rows) => {
rows.push(headerRow())
}),
)
} }
const removeHeader = (index: number) => { const removeHeader = (index: number) => {
if (form.headers.length <= 1) return if (form.headers.length <= 1) return
setForm( setForm("headers", (v) => v.filter((_, i) => i !== index))
"headers", setErrors("headers", (v) => v.filter((_, i) => i !== index))
produce((rows) => {
rows.splice(index, 1)
}),
)
}
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
setForm(key, value)
if (key === "apiKey") return
setForm("err", key, undefined)
}
const setModel = (index: number, key: "id" | "name", value: string) => {
batch(() => {
setForm("models", index, key, value)
setForm("models", index, "err", key, undefined)
})
}
const setHeader = (index: number, key: "key" | "value", value: string) => {
batch(() => {
setForm("headers", index, key, value)
setForm("headers", index, "err", key, undefined)
})
} }
const validate = () => { const validate = () => {
@@ -108,57 +222,52 @@ export function DialogCustomProvider(props: Props) {
disabledProviders: globalSync.data.config.disabled_providers ?? [], disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
}) })
batch(() => { setErrors(output.errors)
setForm("err", output.err)
output.models.forEach((err, index) => setForm("models", index, "err", err))
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
})
return output.result return output.result
} }
const saveMutation = useMutation(() => ({ const save = async (e: SubmitEvent) => {
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { e.preventDefault()
const disabledProviders = globalSync.data.config.disabled_providers ?? [] if (form.saving) return
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) { const result = validate()
await globalSDK.client.auth.set({ if (!result) return
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
key: result.key, key: result.key,
}, },
}) })
} : Promise.resolve()
await globalSync.updateConfig({ auth
provider: { [result.providerID]: result.config }, .then(() =>
disabled_providers: nextDisabled, globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
}) })
return result .catch((err: unknown) => {
}, const message = err instanceof Error ? err.message : String(err)
onSuccess: (result) => { showToast({ title: language.t("common.requestFailed"), description: message })
dialog.close() })
showToast({ .finally(() => {
variant: "success", setForm("saving", false)
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
}) })
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
} }
return ( return (
@@ -196,32 +305,32 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={(v) => setField("providerID", v)} onChange={(v) => setForm("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined} validationState={errors.providerID ? "invalid" : undefined}
error={form.err.providerID} error={errors.providerID}
/> />
<TextField <TextField
label={language.t("provider.custom.field.name.label")} label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")} placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={(v) => setField("name", v)} onChange={(v) => setForm("name", v)}
validationState={form.err.name ? "invalid" : undefined} validationState={errors.name ? "invalid" : undefined}
error={form.err.name} error={errors.name}
/> />
<TextField <TextField
label={language.t("provider.custom.field.baseURL.label")} label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")} placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={(v) => setField("baseURL", v)} onChange={(v) => setForm("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined} validationState={errors.baseURL ? "invalid" : undefined}
error={form.err.baseURL} error={errors.baseURL}
/> />
<TextField <TextField
label={language.t("provider.custom.field.apiKey.label")} label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")} placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")} description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={(v) => setField("apiKey", v)} onChange={(v) => setForm("apiKey", v)}
/> />
</div> </div>
@@ -229,16 +338,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}> <For each={form.models}>
{(m, i) => ( {(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.models.id.label")} label={language.t("provider.custom.models.id.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")} placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id} value={m.id}
onChange={(v) => setModel(i(), "id", v)} onChange={(v) => setForm("models", i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined} validationState={errors.models[i()]?.id ? "invalid" : undefined}
error={m.err.id} error={errors.models[i()]?.id}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -247,9 +356,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")} placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name} value={m.name}
onChange={(v) => setModel(i(), "name", v)} onChange={(v) => setForm("models", i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined} validationState={errors.models[i()]?.name ? "invalid" : undefined}
error={m.err.name} error={errors.models[i()]?.name}
/> />
</div> </div>
<IconButton <IconButton
@@ -273,16 +382,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}> <For each={form.headers}>
{(h, i) => ( {(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.headers.key.label")} label={language.t("provider.custom.headers.key.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")} placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key} value={h.key}
onChange={(v) => setHeader(i(), "key", v)} onChange={(v) => setForm("headers", i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined} validationState={errors.headers[i()]?.key ? "invalid" : undefined}
error={h.err.key} error={errors.headers[i()]?.key}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -291,9 +400,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")} placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value} value={h.value}
onChange={(v) => setHeader(i(), "value", v)} onChange={(v) => setForm("headers", i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined} validationState={errors.headers[i()]?.value ? "invalid" : undefined}
error={h.err.value} error={errors.headers[i()]?.value}
/> />
</div> </div>
<IconButton <IconButton
@@ -313,14 +422,8 @@ export function DialogCustomProvider(props: Props) {
</Button> </Button>
</div> </div>
<Button <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
class="w-auto self-start" {form.saving ? language.t("common.saving") : language.t("common.submit")}
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -29,6 +28,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false, dragOver: false,
iconHover: false, iconHover: false,
}) })
@@ -71,37 +71,38 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "") setStore("iconUrl", "")
} }
const saveMutation = useMutation(() => ({ async function handleSubmit(e: SubmitEvent) {
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate() await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
})
.finally(() => {
setStore("saving", false)
})
} }
return ( return (
@@ -245,8 +246,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}> <Button type="submit" variant="primary" size="large" disabled={store.saving}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")} {store.saving ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
+4 -3
View File
@@ -66,7 +66,6 @@ export const DialogFork: Component = () => {
directory: sdk.directory, directory: sdk.directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk.directory)
sdk.client.session sdk.client.session
.fork({ sessionID, messageID: item.id }) .fork({ sessionID, messageID: item.id })
@@ -76,8 +75,10 @@ export const DialogFork: Component = () => {
return return
} }
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id }) navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
navigate(`/${dir}/session/${forked.data.id}`) requestAnimationFrame(() => {
prompt.set(restored)
})
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,8 +14,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -134,14 +132,9 @@ function createFileEntries(props: {
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => { const recent = createMemo(() => {
const all = tabState.openedTabs() const all = props.tabs().all()
const active = tabState.activeFileTab() const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>() const seen = new Set<string>()
const category = props.language.t("palette.group.files") const category = props.language.t("palette.group.files")
@@ -266,11 +259,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false) const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language }) const commandEntries = createCommandEntries({ filesOnly, command, language })
@@ -426,7 +422,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show> </Show>
</div> </div>
<Show when={item.keybind}> <Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind> <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show> </Show>
</div> </div>
</Match> </Match>
@@ -1,12 +1,9 @@
import { useMutation } from "@tanstack/solid-query" import { Component, createMemo, createSignal, Show } from "solid-js"
import { Component, createEffect, createMemo, on, 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 +17,7 @@ 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({ const [loading, setLoading] = createSignal<string | null>(null)
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 ?? {})
@@ -69,8 +25,10 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = useMutation(() => ({ const toggle = async (name: string) => {
mutationFn: async (name: string) => { if (loading()) return
setLoading(name)
try {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -80,8 +38,10 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
}, } finally {
})) setLoading(null)
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -99,8 +59,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (!x || toggle.isPending) return if (x) toggle(x.name)
toggle.mutate(x.name)
}} }}
> >
{(i) => { {(i) => {
@@ -124,7 +83,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show> </Show>
<Show when={toggle.isPending && toggle.variables === i.name}> <Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show> </Show>
</div> </div>
@@ -133,14 +92,7 @@ export const DialogSelectMcp: Component = () => {
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div> </div>
</div> </div>
) )
@@ -8,29 +8,17 @@ 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"
type ModelState = ReturnType<typeof useLocal>["model"] export const DialogSelectModelUnpaid: Component = () => {
const local = useLocal()
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
const dialog = useDialog() const dialog = useDialog()
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
@@ -47,8 +35,8 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={model.list} items={local.model.list}
current={model.current()} current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => ( itemWrapper={(item, node) => (
<Tooltip <Tooltip
@@ -67,7 +55,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
dialog.close() dialog.close()
@@ -101,7 +89,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 +120,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,28 +10,27 @@ 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"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
onSelect: () => void onSelect: () => void
action?: JSX.Element action?: JSX.Element
model?: ModelState
}> = (props) => { }> = (props) => {
const model = props.model ?? useLocal().model const local = useLocal()
const language = useLanguage() const language = useLanguage()
const models = createMemo(() => const models = createMemo(() =>
model local.model
.list() .list()
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)), .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
) )
@@ -42,7 +41,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
items={models} items={models}
current={model.current()} current={local.model.current()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name} groupBy={(x) => x.provider.name}
@@ -64,7 +63,7 @@ const ModelList: Component<{
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
props.onSelect() props.onSelect()
@@ -89,7 +88,6 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
@@ -105,16 +103,12 @@ export function ModelSelectorPopover(props: {
const handleManage = () => { const handleManage = () => {
setStore("open", false) setStore("open", false)
void import("./dialog-manage-models").then((x) => { dialog.show(() => <DialogManageModels />)
dialog.show(() => <x.DialogManageModels />)
})
} }
const handleConnectProvider = () => { const handleConnectProvider = () => {
setStore("open", false) setStore("open", false)
void import("./dialog-select-provider").then((x) => { dialog.show(() => <DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider />)
})
} }
const language = useLanguage() const language = useLanguage()
@@ -157,7 +151,6 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title> <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
@@ -191,33 +184,30 @@ export function ModelSelectorPopover(props: {
) )
} }
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
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} 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>
@@ -6,7 +6,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -15,9 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -44,15 +41,13 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
}) })
} }
function useDefaultServer() { function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const language = useLanguage() const [defaultUrl, defaultUrlActions] = createResource(
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => { async () => {
try { try {
const key = await platform.getDefaultServer?.() const url = await platform.getDefaultServerUrl?.()
if (!key) return null if (!url) return null
return key return normalizeServerUrl(url) ?? null
} catch (err) { } catch (err) {
showRequestError(language, err) showRequestError(language, err)
return null return null
@@ -61,22 +56,20 @@ function useDefaultServer() {
{ initialValue: null }, { initialValue: null },
) )
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (key: ServerConnection.Key | null) => { const setDefault = async (url: string | null) => {
try { try {
await platform.setDefaultServer?.(key) await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(key) defaultUrlActions.mutate(url)
} catch (err) { } catch (err) {
showRequestError(language, err) showRequestError(language, err)
} }
} }
return { defaultKey, canDefault, setDefault } return { defaultUrl, canDefault, setDefault }
} }
function useServerPreview() { function useServerPreview(fetcher: typeof fetch) {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => { const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return false if (!normalized) return false
@@ -99,7 +92,7 @@ function useServerPreview() {
const http: ServerConnection.HttpBase = { url: normalized } const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username if (username) http.username = username
if (password) http.password = password if (password) http.password = password
const result = await checkServerHealth(http) const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -122,7 +115,7 @@ function ServerForm(props: ServerFormProps) {
return ( return (
<div class="px-5"> <div class="px-5">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3"> <div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField <TextField
type="text" type="text"
@@ -150,7 +143,7 @@ function ServerForm(props: ServerFormProps) {
<TextField <TextField
type="text" type="text"
label={language.t("dialog.server.add.username")} label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder="username"
value={props.username} value={props.username}
disabled={props.busy} disabled={props.busy}
onChange={props.onUsernameChange} onChange={props.onUsernameChange}
@@ -159,7 +152,7 @@ function ServerForm(props: ServerFormProps) {
<TextField <TextField
type="password" type="password"
label={language.t("dialog.server.add.password")} label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder="password"
value={props.password} value={props.password}
disabled={props.busy} disabled={props.busy}
onChange={props.onPasswordChange} onChange={props.onPasswordChange}
@@ -177,16 +170,17 @@ export function DialogSelectServer() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer() const fetcher = platform.fetch ?? globalThis.fetch
const { previewStatus } = useServerPreview() const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const checkServerHealth = useCheckServerHealth() const { previewStatus } = useServerPreview(fetcher)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -198,6 +192,7 @@ export function DialogSelectServer() {
username: "", username: "",
password: "", password: "",
error: "", error: "",
busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
@@ -206,8 +201,9 @@ export function DialogSelectServer() {
setStore("addServer", { setStore("addServer", {
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
@@ -222,78 +218,10 @@ export function DialogSelectServer() {
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false,
}) })
} }
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
@@ -336,7 +264,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {} const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (conn) => { items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -357,12 +285,12 @@ export function DialogSelectServer() {
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -370,12 +298,12 @@ export function DialogSelectServer() {
} }
const handleAddNameChange = (value: string) => { const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { name: value, error: "" }) setStore("addServer", { name: value, error: "" })
} }
const handleAddUsernameChange = (value: string) => { const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" }) setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) => void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -383,7 +311,7 @@ export function DialogSelectServer() {
} }
const handleAddPasswordChange = (value: string) => { const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" }) setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) => void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -391,7 +319,7 @@ export function DialogSelectServer() {
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -399,12 +327,12 @@ export function DialogSelectServer() {
} }
const handleEditNameChange = (value: string) => { const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" }) setStore("editServer", { name: value, error: "" })
} }
const handleEditUsernameChange = (value: string) => { const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" }) setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) => void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -412,13 +340,85 @@ export function DialogSelectServer() {
} }
const handleEditPasswordChange = (value: string) => { const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" }) setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) => void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
) )
} }
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => { const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit" if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add" if (store.addServer.showForm) return "add"
@@ -441,7 +441,7 @@ export function DialogSelectServer() {
showForm: true, showForm: true,
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
@@ -458,26 +458,23 @@ export function DialogSelectServer() {
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
}) })
} }
const submitForm = () => { const submitForm = () => {
if (mode() === "add") { if (mode() === "add") {
if (addMutation.isPending) return void handleAdd(store.addServer.url)
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return return
} }
const original = editing() const original = editing()
if (!original) return if (!original) return
if (editMutation.isPending) return void handleEdit(original, store.editServer.value)
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
} }
const isFormMode = createMemo(() => mode() !== "list") const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add") const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formTitle = createMemo(() => { const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title") if (!isFormMode()) return language.t("dialog.server.title")
@@ -497,8 +494,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServer?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServer?.(null) platform.setDefaultServerUrl?.(null)
} }
} }
@@ -539,7 +536,7 @@ export function DialogSelectServer() {
if (x) select(x) if (x) select(x)
}} }}
divider={true} divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i) const key = ServerConnection.key(i)
@@ -554,7 +551,7 @@ export function DialogSelectServer() {
status={store.status[key]} status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={defaultKey() === ServerConnection.key(i)}> <Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -587,14 +584,14 @@ export function DialogSelectServer() {
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}> <Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(key)}> <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={canDefault() && defaultKey() === key}> <Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}> <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
+253 -322
View File
@@ -1,7 +1,8 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { import {
@@ -16,6 +17,7 @@ import {
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -25,20 +27,18 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
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"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
@@ -48,7 +48,7 @@ import {
type PromptHistoryStoredEntry, type PromptHistoryStoredEntry,
promptLength, promptLength,
} from "./prompt-input/history" } from "./prompt-input/history"
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items" import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
@@ -61,11 +61,6 @@ interface PromptInputProps {
ref?: (el: HTMLDivElement) => void ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
} }
@@ -107,21 +102,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
const params = useParams()
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const command = useCommand() const command = useCommand()
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 44
const space = `${inset}px`
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
const container = scrollRef const container = scrollRef
@@ -156,18 +150,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const queueScroll = (count = 2) => { const queueScroll = () => {
requestAnimationFrame(() => { requestAnimationFrame(scrollCursorIntoView)
scrollCursorIntoView()
if (count > 1) queueScroll(count - 1)
})
} }
const activeFileTab = createSessionTabs({ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
tabs, const tabs = createMemo(() => layout.tabs(sessionKey))
pathFromTab: files.pathFromTab, const view = createMemo(() => layout.view(sessionKey))
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const commentInReview = (path: string) => { const commentInReview = (path: string) => {
const sessionID = params.id const sessionID = params.id
@@ -220,7 +209,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const recent = createMemo(() => { const recent = createMemo(() => {
const all = tabs().all() const all = tabs().all()
const active = activeFileTab() const active = tabs().active()
const order = active ? [active, ...all.filter((x) => x !== active)] : all const order = active ? [active, ...all.filter((x) => x !== active)] : all
const seen = new Set<string>() const seen = new Set<string>()
const paths: string[] = [] const paths: string[] = []
@@ -243,23 +232,6 @@ 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"),
) )
@@ -283,15 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
const buttons = createMemo(() => motion(buttonsSpring()))
const shell = createMemo(() => motion(1 - buttonsSpring()))
const control = createMemo(() => ({ height: "28px", ...buttons() }))
const commentCount = createMemo(() => { const commentCount = createMemo(() => {
if (store.mode === "shell") return 0 if (store.mode === "shell") return 0
@@ -427,6 +390,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click() const pick = () => fileInputRef?.click()
@@ -526,18 +490,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false) setComposing(false)
} }
const handleCompositionStart = () => {
setComposing(true)
}
const handleCompositionEnd = () => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
})
}
const agentList = createMemo(() => const agentList = createMemo(() =>
sync.data.agent sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -571,7 +523,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent() const open = recent()
const seen = new Set(open) const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query) const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))
@@ -622,20 +573,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
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")
} }
@@ -712,20 +661,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
}) })
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -745,27 +680,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const reconcile = (input: Prompt) => {
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(input)
return
}
const dom = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
renderEditorWithCursor(input)
}
createEffect( createEffect(
on( on(
() => prompt.current(), () => prompt.current(),
(parts) => { (currentParts) => {
if (composing()) return const inputParts = currentParts.filter((part) => part.type !== "image")
reconcile(parts.filter((part) => part.type !== "image"))
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts)
}, },
), ),
) )
@@ -989,45 +921,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setCurrentHistory("entries", next) setCurrentHistory("entries", next)
} }
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
for (const item of edit.context) {
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
setStore("mode", "normal")
setStore("popover", null)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
prompt.set(edit.prompt, promptLength(edit.prompt))
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(edit.prompt))
queueScroll()
})
props.onEditLoaded?.()
},
{ defer: true },
),
)
const navigateHistory = (direction: "up" | "down") => { const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({ const result = navigatePromptHistory({
direction, direction,
@@ -1044,8 +937,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => { focusEditor: () => {
@@ -1062,17 +956,6 @@ 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,
@@ -1092,9 +975,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setPopover: (popover) => setStore("popover", popover), setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree, newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
}) })
@@ -1242,20 +1122,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Note: Shift+Enter is handled earlier, before IME check // Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
return
}
handleSubmit(event) handleSubmit(event)
} }
} }
@@ -1308,7 +1174,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
} }
onRemove={removeAttachment} onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
/> />
<div <div
@@ -1326,11 +1192,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef?.focus() editorRef?.focus()
}} }}
> >
<div <div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={(el) => {
@@ -1344,80 +1206,46 @@ 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={() => setComposing(true)}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true, "select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true, "[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
style={{ "padding-bottom": space }}
/> />
<Show when={!prompt.dirty()}> <Show when={!prompt.dirty()}>
<div <div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }} classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space }}
> >
{placeholder()} {placeholder()}
</div> </div>
</Show> </Show>
</div> </div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2"> <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const list = e.currentTarget.files const file = e.currentTarget.files?.[0]
if (list) void addAttachments(Array.from(list)) if (file) addImageAttachment(file)
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div <div
aria-hidden={store.mode !== "normal"} aria-hidden={store.mode !== "normal"}
class="pointer-events-auto" class="flex items-center gap-1"
style={{ style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}} }}
@@ -1432,7 +1260,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
variant="ghost" variant="ghost"
class="size-8 p-0" class="size-8 p-0"
style={buttons()} style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick} onClick={pick}
disabled={store.mode !== "normal"} disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1441,6 +1273,85 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="plus" class="size-4.5" /> <Icon name="plus" class="size-4.5" />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<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>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t(
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": accepting() }}
/>
</Button>
</TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@@ -1453,91 +1364,65 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0" class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{ style={{
padding: "0 4px 0 8px", padding: "0 4px 0 8px",
...shell(), opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}} }}
> >
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<div data-component="prompt-agent-control"> <TooltipKeybind
<TooltipKeybind placement="top"
placement="top" gutter={4}
gutter={4} title={language.t("command.agent.cycle")}
title={language.t("command.agent.cycle")} keybind={command.keybind("agent.cycle")}
keybind={command.keybind("agent.cycle")} >
> <Select
<Select size="normal"
size="normal" options={agentNames()}
options={agentNames()} current={local.agent.current()?.name ?? ""}
current={local.agent.current()?.name ?? ""} onSelect={local.agent.set}
onSelect={local.agent.set} class="capitalize max-w-[160px]"
class="capitalize max-w-[160px] text-text-base" valueClass="truncate text-13-regular"
valueClass="truncate text-13-regular text-text-base" triggerStyle={{
triggerStyle={control()} height: "28px",
triggerProps={{ "data-action": "prompt-agent" }} opacity: buttonsSpring(),
variant="ghost" transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
/> filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
</TooltipKeybind> "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
</div> }}
<div data-component="prompt-model-control"> variant="ghost"
<Show />
when={providers.paid().length > 0} </TooltipKeybind>
fallback={ <Show
<TooltipKeybind when={providers.paid().length > 0}
placement="top" fallback={
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover <Button
model={local.model} as="div"
triggerAs={Button} variant="ghost"
triggerProps={{ size="normal"
variant: "ghost", class="min-w-0 max-w-[320px] text-13-regular group"
size: "normal", style={{
style: control(), height: "28px",
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", opacity: buttonsSpring(),
"data-action": "prompt-model", transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}} }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={local.model.current()?.provider?.id ?? ""} id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@@ -1546,55 +1431,101 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover> </Button>
</TooltipKeybind> </TooltipKeybind>
</Show> }
</div> >
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.variant.cycle")} title={language.t("command.model.choose")}
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("model.choose")}
> >
<Select <ModelSelectorPopover
size="normal" triggerAs={Button}
options={variants()} triggerProps={{
current={local.model.variant.current() ?? "default"} variant: "ghost",
label={(x) => (x === "default" ? language.t("common.default") : x)} size: "normal",
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} style: {
class="capitalize max-w-[160px] text-text-base" height: "28px",
valueClass="truncate text-13-regular text-text-base" opacity: buttonsSpring(),
triggerStyle={control()} transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
triggerProps={{ "data-action": "prompt-model-variant" }} filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
variant="ghost" "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
/> },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</div> </Show>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={8} gutter={4}
title={acceptLabel()} title={language.t("command.model.variant.cycle")}
keybind={command.keybind("permissions.autoaccept")} keybind={command.keybind("model.variant.cycle")}
> >
<Button <Select
data-action="prompt-permissions" size="normal"
variant="ghost" options={variants()}
onClick={toggleAccept} current={local.model.variant.current() ?? "default"}
classList={{ label={(x) => (x === "default" ? language.t("common.default") : x)}
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true, onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
"text-text-base": !accepting(), class="capitalize max-w-[160px]"
"hover:bg-surface-success-base": accepting(), valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}} }}
style={control()} variant="ghost"
aria-label={acceptLabel()} />
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
<div class="shrink-0">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
</div>
</div> </div>
</DockTray> </DockTray>
</Show> </Show>
@@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
expect(await attachmentMime(file)).toBe("application/pdf")
})
test("normalizes structured text types to text/plain", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("rejects binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
@@ -4,28 +4,26 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) { export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
return new Promise<string>((resolve) => { export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
const reader = new FileReader() const LARGE_PASTE_CHARS = 8000
reader.addEventListener("error", () => resolve("")) const LARGE_PASTE_BREAKS = 120
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : "" function largePaste(text: string) {
const idx = value.indexOf(",") if (text.length >= LARGE_PASTE_CHARS) return true
if (idx === -1) { let breaks = 0
resolve(value) for (const char of text) {
return if (char !== "\n") continue
} breaks += 1
resolve(`data:${mime};base64,${value.slice(idx + 1)}`) if (breaks >= LARGE_PASTE_BREAKS) return true
}) }
reader.readAsDataURL(file) return false
})
} }
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
@@ -37,73 +35,58 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const warn = () => { const addImageAttachment = async (file: File) => {
showToast({ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"), const reader = new FileReader()
}) reader.onload = () => {
const editor = input.editor()
if (!editor) return
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
} }
const add = async (file: File, toast = true) => { const removeImageAttachment = (id: string) => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => {
const current = prompt.current() const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor()) prompt.set(next, prompt.cursor())
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const files = Array.from(clipboardData.items).flatMap((item) => { const items = Array.from(clipboardData.items)
if (item.kind !== "file") return [] const fileItems = items.filter((item) => item.kind === "file")
const file = item.getAsFile() const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
return file ? [file] : []
})
if (files.length > 0) { if (imageItems.length > 0) {
await addAttachments(files) for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
}
return
}
if (fileItems.length > 0) {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
return return
} }
@@ -113,30 +96,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.readClipboardImage && !plainText) { if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage() const file = await input.readClipboardImage()
if (file) { if (file) {
await addAttachment(file) await addImageAttachment(file)
return return
} }
} }
if (!plainText) return if (!plainText) return
const text = normalizePaste(plainText) if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor() input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 }) if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
} }
if (pasteMode(text) === "manual") { const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return if (inserted) return
put() input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
@@ -177,7 +153,11 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
await addAttachments(Array.from(dropped)) for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
}
} }
onMount(() => { onMount(() => {
@@ -193,9 +173,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}) })
return { return {
addAttachment, addImageAttachment,
addAttachments, removeImageAttachment,
removeAttachment,
handlePaste, handlePaste,
} }
} }
@@ -49,32 +49,6 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
}) })
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
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 }]
@@ -100,30 +74,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,66 +0,0 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
const SAMPLE = 4096
function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
function ext(name: string) {
const idx = name.lastIndexOf(".")
if (idx === -1) return ""
return name.slice(idx + 1).toLowerCase()
}
function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}
function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}
export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
}
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc" const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,14 +135,11 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false) expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0 const atStart = position === 0
const atEnd = position === text.length const atEnd = position === text.length
if (inHistory) return atStart || atEnd if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 && text.length === 0 if (direction === "up") return position === 0
return position === text.length return position === text.length
} }
@@ -1,24 +0,0 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}
@@ -7,17 +7,12 @@ const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string
sessionID?: string
message: { message: {
agent: string agent: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -33,12 +28,7 @@ const clientFor = (directory: string) => {
session: { session: {
create: async () => { create: async () => {
createdSessions.push(directory) createdSessions.push(directory)
return { return { data: { id: `session-${createdSessions.length}` } }
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
}, },
shell: async () => { shell: async () => {
sentShell.push(directory) sentShell.push(directory)
@@ -87,11 +77,6 @@ beforeAll(async () => {
agent: { agent: {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}), }),
})) }))
@@ -144,16 +129,9 @@ beforeAll(async () => {
session: { session: {
optimistic: { optimistic: {
add: (value: { add: (value: {
directory?: 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(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
}, },
remove: () => undefined, remove: () => undefined,
}, },
@@ -166,21 +144,7 @@ beforeAll(async () => {
useGlobalSync: () => ({ useGlobalSync: () => ({
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
storedSessions[directory] ??= [] return [{}, () => undefined]
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
}, },
}), }),
})) }))
@@ -206,14 +170,11 @@ beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
params = {} params = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
@@ -246,12 +207,7 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => { test("applies auto-accept to newly created sessions", async () => {
@@ -315,32 +271,4 @@ describe("prompt submit worktree selection", () => {
}, },
}) })
}) })
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
}) })
@@ -1,7 +1,6 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Binary } from "@opencode-ai/util/binary"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
@@ -10,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { 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 { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
@@ -26,145 +25,6 @@ type PendingPrompt = {
const pending = new Map<string, PendingPrompt>() const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = {
sessionID: string
sessionDirectory: string
prompt: Prompt
context: (ContextItem & { key: string })[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "busy" })
}
const setIdle = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
await input.client.session.command({
sessionID: input.draft.sessionID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
return true
} catch (err) {
setIdle()
throw err
}
}
const messageID = input.messageID ?? Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
id: messageID,
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
}
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
setBusy()
add()
try {
if (!(await wait())) {
setIdle()
remove()
return false
}
await input.client.session.promptAsync({
sessionID: input.draft.sessionID,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant,
})
return true
} catch (err) {
setIdle()
remove()
throw err
}
}
type PromptSubmitInput = { type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined> info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
@@ -181,9 +41,6 @@ type PromptSubmitInput = {
setPopover: (popover: "at" | "slash" | null) => void setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: Accessor<string | undefined> newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
} }
@@ -225,8 +82,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const [, setStore] = globalSync.child(sdk.directory) const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, []) setStore("todo", sessionID, [])
input.onAbort?.()
const queued = pending.get(sessionID) const queued = pending.get(sessionID)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
@@ -261,26 +116,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
}
const seed = (dir: string, info: Session) => {
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
@@ -296,7 +131,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current() const currentModel = local.model.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) { if (!currentModel || !currentAgent) {
showToast({ showToast({
title: language.t("prompt.toast.modelAgentRequired.title"), title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -357,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await client.session session = await client.session
.create() .create()
.then((x) => x.data ?? undefined) .then((x) => x.data ?? undefined)
.catch((err) => { .catch((err) => {
@@ -367,11 +201,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return undefined return undefined
}) })
if (created) { if (session) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -384,21 +215,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
input.onSubmit?.()
const model = { const model = {
modelID: currentModel.id, modelID: currentModel.id,
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const context = prompt.context.items().slice() const variant = local.model.variant.current()
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => { const clearInput = () => {
prompt.reset() prompt.reset()
@@ -419,15 +243,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
} }
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext()
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
client.session client.session
@@ -480,19 +295,48 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const context = prompt.context.items().slice()
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => { const messageID = Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: currentPrompt,
context,
images,
text,
sessionID: session.id,
messageID,
sessionDirectory,
})
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
model,
variant,
}
const addOptimisticMessage = () =>
sync.session.optimistic.add({
directory: sessionDirectory,
sessionID: session.id,
message: optimisticMessage,
parts: optimisticParts,
})
const removeOptimisticMessage = () =>
sync.session.optimistic.remove({ sync.session.optimistic.remove({
directory: sessionDirectory, directory: sessionDirectory,
sessionID: session.id, sessionID: session.id,
messageID, messageID,
}) })
}
removeCommentItems(commentItems) removeCommentItems(commentItems)
clearInput() clearInput()
addOptimisticMessage()
const waitForWorktree = async () => { const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory) const worktree = WorktreeState.get(sessionDirectory)
@@ -549,15 +393,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return true return true
} }
void sendFollowupDraft({ const send = async () => {
client, const ok = await waitForWorktree()
sync, if (!ok) return
globalSync, await client.session.promptAsync({
draft, sessionID: session.id,
messageID, agent,
optimisticBusy: sessionDirectory === projectDirectory, model,
before: waitForWorktree, messageID,
}).catch((err) => { parts: requestParts,
variant,
})
}
void send().catch((err) => {
pending.delete(session.id) pending.delete(session.id)
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session.id, { type: "idle" })
@@ -10,7 +10,6 @@ import {
type ParentProps, type ParentProps,
Show, Show,
} from "solid-js" } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server" import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
@@ -26,7 +25,6 @@ interface ServerRowProps extends ParentProps {
} }
export function ServerRow(props: ServerRowProps) { export function ServerRow(props: ServerRowProps) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
@@ -67,26 +65,22 @@ export function ServerRow(props: ServerRowProps) {
return ( return (
<Tooltip <Tooltip
class="flex-1 min-w-0" class="flex-1"
value={tooltipValue()} value={tooltipValue()}
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
placement="top-start" placement="top-start"
inactive={!truncated() && !props.conn.displayName} inactive={!truncated() && !props.conn.displayName}
> >
<div class={props.class} classList={{ "opacity-50": props.dimmed }}> <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div class="flex flex-col items-start min-w-0 w-full"> <div class="flex flex-col items-start">
<div class="flex flex-row items-center gap-2 min-w-0 w-full"> <div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}> <span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()} {name()}
</span> </span>
<Show <Show
when={badge()} when={badge()}
fallback={ fallback={
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version} v{props.status?.version}
</span> </span>
</Show> </Show>
@@ -102,7 +96,7 @@ export function ServerRow(props: ServerRowProps) {
{conn().http.username ? ( {conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span> <span class="text-text-weak">{conn().http.username}</span>
) : ( ) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span> <span class="text-text-weaker">no username</span>
)} )}
</span> </span>
{conn().http.password && <span class="text-text-weak"></span>} {conn().http.password && <span class="text-text-weak"></span>}
@@ -2,15 +2,12 @@ import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
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 { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
@@ -30,18 +27,14 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const file = useFile() const params = useParams()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const providers = useProviders()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const tabState = createSessionTabs({ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
tabs, const tabs = createMemo(() => layout.tabs(sessionKey))
pathFromTab: file.pathFromTab, const view = createMemo(() => layout.view(sessionKey))
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo( const usd = createMemo(
@@ -52,7 +45,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)
@@ -61,7 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (tabState.activeTab() === "context") { if (tabs().active() === "context") {
tabs().close("context") tabs().close("context")
return return
} }
@@ -1,6 +1,8 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
@@ -12,8 +14,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 { 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"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@@ -91,11 +91,13 @@ const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const params = useParams()
const sync = useSync() const sync = useSync()
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const providers = useProviders()
const { params, view } = useSessionLayout()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
@@ -132,7 +134,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()))
@@ -269,14 +271,14 @@ export function SessionContextTab() {
return ( return (
<ScrollView <ScrollView
class="@container h-full" class="@container h-full pb-10"
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div class="px-6 pt-4 pb-10 flex flex-col gap-10"> <div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}> <For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />} {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}

Some files were not shown because too many files have changed in this diff Show More