Compare commits

..

23 Commits

Author SHA1 Message Date
David Hill
0fc4a73553 tweak(ui): use new-session icon in sidebar buttons 2026-03-13 00:04:43 +00:00
David Hill
77b49135de fix(app): avoid clipping new session during sidebar anim 2026-03-12 23:40:09 +00:00
David Hill
d89b652163 fix(app): animate titlebar controls on sidebar open 2026-03-12 23:23:03 +00:00
David Hill
d86b00ebd0 tui: remove shell/normal mode toggle from prompt input
Users no longer need to manually switch between shell and normal modes as the agent now automatically determines the appropriate mode based on context.
2026-03-12 17:23:05 +00:00
David Hill
69e3924aaf tui: fix status indicator positioning for better visual alignment 2026-03-12 17:13:39 +00:00
David Hill
58db12de6c tweak(ui): replace app icons 2026-03-12 16:43:55 +00:00
David Hill
d0b4c5d3d7 fix(app): normalize open app icon sizing 2026-03-12 16:40:02 +00:00
David Hill
d25e9426fd fix(app): adjust open dropdown spacing 2026-03-12 14:02:43 +00:00
David Hill
c12b2b75bb fix(app): tone down search shortcut color 2026-03-12 12:58:20 +00:00
David Hill
23980fb62d fix(ui): stabilize dropdown menu item layout 2026-03-12 12:56:03 +00:00
David Hill
e7988d9e12 fix(app): tighten open button spacing 2026-03-12 12:42:24 +00:00
David Hill
7242d572ba fix(app): keep server tooltip on one line 2026-03-12 12:31:16 +00:00
David Hill
f0bc54f096 fix(app): truncate server row in status popover 2026-03-12 12:28:33 +00:00
David Hill
08d090b10a feat(app): add status tooltip 2026-03-12 12:17:01 +00:00
David Hill
648e64082b feat(app): add status titlebar icon with health badge 2026-03-12 12:13:28 +00:00
David Hill
8593e6ad67 fix(ui): update sidebar and new session icons 2026-03-12 11:37:43 +00:00
David Hill
1995064bc5 fix(ui): update terminal and review icons 2026-03-12 11:15:46 +00:00
David Hill
4b1e6ac557 feat(app): refresh titlebar icons
Swap titlebar controls to the new terminal/review/file tree/sidebar/new session icons with explicit active variants, and style navigation-active titlebar buttons via aria-current.
2026-03-12 11:07:31 +00:00
David Hill
719828958b revert(app): remove shared link button in session header
Drop the inline share-url copy icon next to session context, returning the session header controls to context + options only.
2026-03-11 16:46:25 +00:00
David Hill
fe4d4a7a3a feat(app): add shared link button in session header
When a session is published, show a link icon next to context that copies the share URL with a transient check + tooltip, and animate the header controls to make room for it.
2026-03-11 16:06:00 +00:00
David Hill
d2747d70d6 fix(app): keep session options button active while sharing
Avoid a one-frame active-state flicker when transitioning from the options dropdown to the share popover by treating the pending-share state as active until the popover opens.
2026-03-11 15:45:06 +00:00
David Hill
5ca2dc87b7 fix(app): move session share into options menu
Remove the title bar share control and surface sharing from the session options dropdown, opening the share popover in-place after selection to avoid flicker and keep consistent dismissal behavior.
2026-03-11 15:39:59 +00:00
David Hill
21f562fdae tui: make context group header non-clickable except for arrow icon
Users now see that only the arrow icon expands/collapses context groups,
not the entire row, making the UI behavior more predictable.
2026-03-11 14:57:57 +00:00
1353 changed files with 85578 additions and 177924 deletions

10
.github/VOUCHED.td vendored
View File

@@ -10,10 +10,6 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
-florianleibert
fwang
@@ -21,13 +17,7 @@ iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot

View File

@@ -3,6 +3,14 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
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
id: bun-url
shell: bash
@@ -23,31 +31,10 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
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
run: |
# 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
run: bun install
shell: bash

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

View File

@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3

View File

@@ -9,8 +9,7 @@ on:
jobs:
sync-locales:
if: false
#if: github.actor != 'opencode-agent[bot]'
if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -35,7 +34,7 @@ jobs:
- name: Compute changed English docs
id: changes
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
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"

View File

@@ -17,10 +17,6 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# 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
# 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
echo "::error::Failed to compute hash for ${SYSTEM}"

View File

@@ -98,129 +98,15 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: |
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*
path: packages/opencode/dist
outputs:
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:
needs:
- build-cli
- version
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:
fail-fast: false
matrix:
@@ -229,9 +115,6 @@ jobs:
target: x86_64-apple-darwin
- host: macos-latest
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
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
@@ -266,14 +149,6 @@ jobs:
- 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"
@@ -312,7 +187,6 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -369,35 +243,11 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
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:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
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:
fail-fast: false
matrix:
@@ -408,10 +258,6 @@ jobs:
- host: macos-latest
target: aarch64-apple-darwin
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"
target: x86_64-pc-windows-msvc
platform_flag: --win
@@ -422,6 +268,7 @@ jobs:
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
@@ -438,14 +285,6 @@ jobs:
- 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"
@@ -480,7 +319,6 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -513,22 +351,6 @@ jobs:
env:
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
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -544,10 +366,8 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -584,16 +404,6 @@ jobs:
name: opencode-cli
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
if: needs.version.outputs.release
with:

54
.github/workflows/sign-cli.yml vendored Normal file
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
.github/workflows/stale-issues.yml vendored Normal file
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"

View File

@@ -8,17 +8,11 @@ on:
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)) }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
unit:
@@ -41,11 +35,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -54,53 +43,25 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test:ci
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
run: bun turbo test
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
PLAYWRIGHT_BROWSERS_PATH: 0
defaults:
run:
shell: bash
@@ -110,52 +71,26 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@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
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
run: ${{ matrix.settings.playwright }}
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always()
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report

View File

@@ -33,6 +33,6 @@ jobs:
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
roles: admin,maintain
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

3
.gitignore vendored
View File

@@ -17,7 +17,7 @@ ts-dist
/result
refs
Session.vim
/opencode.json
opencode.json
a.out
target
.scripts
@@ -25,7 +25,6 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,7 +1,4 @@
node_modules
plans
package.json
plans/
bun.lock
.gitignore
package.json
package-lock.json
references/

34
.opencode/agent/docs.md Normal file
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:`

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gpt-5.4
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.

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>

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"
}
}
}

View File

@@ -1,937 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
const look = (map: Record<string, unknown>) => {
return {
panel: ink(map, "backgroundPanel", ui.panel),
border: ink(map, "border", ui.border),
text: ink(map, "text", ui.text),
muted: ink(map, "textMuted", ui.muted),
accent: ink(map, "primary", ui.accent),
selected: ink(map, "selectedListItemText", ui.text),
}
}
const tone = (api: TuiPluginApi) => {
return look(api.theme.current)
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiPluginApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiPluginApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiPluginApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiPluginApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiPluginApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginMeta
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
params?: Record<string, unknown>
}) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
const skin = look(map)
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const fill = [
skin.accent,
skin.muted,
ink(map, "info", ui.accent),
skin.text,
ink(map, "success", ui.accent),
ink(map, "warning", ui.accent),
ink(map, "secondary", ui.accent),
ink(map, "error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={fill[i]}>{line}</text>
))}
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
</text>
</box>
</box>
)
},
},
})
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
order,
slots: {
sidebar_content(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
},
},
})
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
]
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
api.lifecycle.onDispose(() => {
api.renderer.removePostProcessFn(post)
})
api.route.register([
{
name: route.screen,
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(api, value, keys)
for (const item of slot(api, value)) {
api.slots.register(item)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin

View File

@@ -1,21 +0,0 @@
---
name: effect
description: Answer questions about the Effect framework
---
# Effect
This codebase uses Effect, a framework for writing typescript.
## How to Answer Effect Questions
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
`.opencode/references/effect-smol` in this project NOT the skill folder.
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
3. Provide responses based on the actual Effect source code and documentation
## Guidelines
- Always use the explore agent with the cloned repository when answering Effect-related questions
- Reference specific files and patterns found in the Effect codebase
- Do not answer from memory - always verify against the source

View File

@@ -1 +0,0 @@
smoke-theme.json

View File

@@ -116,8 +116,8 @@
"light": "nord5"
},
"diffLineNumber": {
"dark": "#abafb7",
"light": "textMuted"
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#3B4252",

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -22,16 +24,7 @@ interface PR {
}
export default tool({
description: `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.`,
description: DESCRIPTION,
args: {
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),

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.

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
@@ -38,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `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.`,
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -0,0 +1,6 @@
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.

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"
}
}
]
]
}

View File

@@ -0,0 +1,5 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

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)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

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)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

1803
bun.lock

File diff suppressed because it is too large Load Diff

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1773909469,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"type": "github"
},
"original": {

View File

@@ -496,6 +496,7 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],

View File

@@ -103,18 +103,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go",
})
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -128,9 +116,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
},
})
@@ -209,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_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", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -231,12 +212,8 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-gdS7MkWGeVO0qLs0HKD156YE0uCk5vWeYjKu4JR1Apw=",
"aarch64-linux": "sha256-tF4pyVqzbrvdkRG23Fot37FCg8guRZkcU738fHPr/OQ=",
"aarch64-darwin": "sha256-FugTWzGMb2ktAbNwQvWRM3GWOb5RTR++8EocDDrQMLc=",
"x86_64-darwin": "sha256-jpe6EiwKr+CS00cn0eHwcDluO4LvO3t/5l/LcFBBKP0="
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
}
}

View File

@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
version = "${packageJson.version}-${rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,7 +54,6 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -3,10 +3,10 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -19,7 +19,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -30,8 +29,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';
@@ -51,25 +48,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase =
''
runHook preInstall
installPhase = ''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
''
# bun runs sysctl to detect if dunning on rosetta2
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath [
sysctl
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
]
}
''
+ ''
runHook postInstall
'';
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions

View File

@@ -4,15 +4,13 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.11",
"packageManager": "bun@1.3.10",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri 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",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -26,10 +24,7 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -46,19 +41,17 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"cross-spawn": "7.0.6",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.29",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
@@ -72,8 +65,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
"vite-plugin-solid": "2.11.10"
}
},
"devDependencies": {
@@ -93,7 +85,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {
@@ -107,11 +98,9 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
@@ -121,6 +110,6 @@
},
"patchedDependencies": {
"@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"
}
}

View File

@@ -31,10 +31,11 @@ Your app is ready to be deployed!
## E2E Testing
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
Use the local runner to create a temp sandbox, seed data, and run the tests.
```bash
bunx playwright install chromium
bunx playwright install
bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```

188
packages/app/e2e/AGENTS.md Normal file
View File

@@ -0,0 +1,188 @@
# E2E Testing Guide
## Build/Lint/Test Commands
```bash
# Run all e2e tests
bun test:e2e
# Run specific test file
bun test:e2e -- app/home.spec.ts
# Run single test by title
bun test:e2e -- -g "home renders and shows core entrypoints"
# Run tests with UI mode (for debugging)
bun test:e2e:ui
# Run tests locally with full server setup
bun test:e2e:local
# View test report
bun test:e2e:report
# Typecheck
bun typecheck
```
## Test Structure
All tests live in `packages/app/e2e/`:
```
e2e/
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
├── actions.ts # Reusable action helpers
├── selectors.ts # DOM selectors
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
└── [feature]/
└── *.spec.ts # Test files
```
## Test Patterns
### Basic Test Structure
```typescript
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("test description", async ({ page, sdk, gotoSession }) => {
await gotoSession() // or gotoSession(sessionID)
// Your test code
await expect(page.locator(promptSelector)).toBeVisible()
})
```
### Using Fixtures
- `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls
- `gotoSession(sessionID?)` - Navigate to session
### Helper Functions
**Actions** (`actions.ts`):
- `openPalette(page)` - Open command palette
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
- `promptSelector` - Prompt input
- `terminalSelector` - Terminal panel
- `sessionItemSelector(id)` - Session in sidebar
- `listItemSelector` - Generic list items
**Utils** (`utils.ts`):
- `modKey` - Meta (Mac) or Control (Linux/Win)
- `serverUrl` - Backend server URL
- `sessionPath(dir, id?)` - Build session URL
## Code Style Guidelines
### Imports
Always import from `../fixtures`, not `@playwright/test`:
```typescript
// ✅ Good
import { test, expect } from "../fixtures"
// ❌ Bad
import { test, expect } from "@playwright/test"
```
### Naming Conventions
- Test files: `feature-name.spec.ts`
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
- Variables: camelCase
- Constants: SCREAMING_SNAKE_CASE
### Error Handling
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session", async (session) => {
await gotoSession(session.id)
// Test code...
}) // Auto-deletes session
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
```typescript
test.setTimeout(120_000) // For long LLM operations
test("slow test", async () => {
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
})
```
### Selectors
Use `data-component`, `data-action`, or semantic roles:
```typescript
// ✅ Good
await page.locator('[data-component="prompt-input"]').click()
await page.getByRole("button", { name: "Open settings" }).click()
// ❌ Bad
await page.locator(".css-class-name").click()
await page.locator("#id-name").click()
```
### Keyboard Shortcuts
Use `modKey` for cross-platform compatibility:
```typescript
import { modKey } from "../utils"
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
## Writing New Tests
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development
For UI debugging, use:
```bash
bun test:e2e:ui
```
This opens Playwright's interactive UI for step-through debugging.

713
packages/app/e2e/actions.ts Normal file
View File

@@ -0,0 +1,713 @@
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
export async function defocus(page: Page) {
await page
.evaluate(() => {
const el = document.activeElement
if (el instanceof HTMLElement) el.blur()
})
.catch(() => undefined)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
return dialog
}
export async function closeDialog(page: Page, dialog: Locator) {
await page.keyboard.press("Escape")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await page.keyboard.press("Escape")
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closedSecond) return
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
}
export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true"
}
export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
}
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
}
export async function openSettings(page: Page) {
await defocus(page)
const dialog = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (opened) return dialog
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
}
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { 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', {
cwd: root,
stdio: "ignore",
})
return resolveDirectory(root)
}
export async function cleanupTestProject(directory: string) {
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return next
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
}
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
.first()
const opened = await menu
.isVisible()
.then((x) => x)
.catch(() => false)
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
await expect(menu).toBeVisible()
return menu
}
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
await expect(item).toBeVisible()
await item.click({ force: options?.force })
}
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
const dialog = page.getByRole("dialog").first()
await expect(dialog).toBeVisible()
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function openSharePopover(page: Page) {
const rightSection = page.locator(titlebarRightSelector)
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(shareButton).toBeVisible()
const popoverBody = page
.locator(popoverBodySelector)
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first()
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await shareButton.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function clickListItem(
container: Locator | Page,
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
): Promise<Locator> {
let item: Locator
if (typeof filter === "string" || filter instanceof RegExp) {
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
} else if (filter.keyStartsWith) {
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
} else if (filter.key) {
item = container.locator(listItemKeySelector(filter.key)).first()
} else if (filter.text) {
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
} else {
throw new Error("Invalid filter provided to clickListItem")
}
await expect(item).toBeVisible()
await item.click()
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
callback: (session: { id: string; title: string }) => Promise<T>,
): Promise<T> {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await callback(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) {
await defocus(page)
const rightSection = page.locator(titlebarRightSelector)
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await expect(trigger).toBeVisible()
await trigger.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
throw new Error(`Failed to open project menu: ${projectSlug}`)
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
const flipped = await flip(1500)
.then(() => true)
.catch(() => false)
if (!flipped) await flip()
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
return menu
}

View File

@@ -0,0 +1,21 @@
import { test, expect } from "../fixtures"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})

View File

@@ -0,0 +1,10 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { dirPath } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})

View File

@@ -0,0 +1,11 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})

View File

@@ -0,0 +1,58 @@
import { test, expect } from "../fixtures"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
test("can set a default server on web", async ({ page, gotoSession }) => {
await page.addInitScript((key: string) => {
try {
localStorage.removeItem(key)
} catch {
return
}
}, DEFAULT_SERVER_URL_KEY)
await gotoSession()
const status = page.getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" })
const ensurePopoverOpen = async () => {
if (await popover.isVisible()) return
await status.click()
await expect(popover).toBeVisible()
}
await ensurePopoverOpen()
await popover.getByRole("button", { name: "Manage servers" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})

View File

@@ -0,0 +1,16 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
await withSession(sdk, title, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
})
})

View File

@@ -0,0 +1,120 @@
import { test, expect } from "../fixtures"
import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeEnabled()
await forward.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
await gotoSession(a.id)
await openSidebar(page)
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeDisabled()
})
})
})
})
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+[`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+]`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})

View File

@@ -0,0 +1,15 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})

View File

@@ -0,0 +1,33 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})

View File

@@ -0,0 +1,32 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})

View File

@@ -0,0 +1,31 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})

View File

@@ -0,0 +1,56 @@
import { test, expect } from "../fixtures"
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
const toggle = page.getByRole("button", { name: "Toggle file tree" })
const panel = page.locator("#file-tree-panel")
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
await expect(toggle).toBeVisible()
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toBeVisible()
await expect(treeTabs).toBeVisible()
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
await expect(allTab).toBeVisible()
await allTab.click()
await expect(allTab).toHaveAttribute("aria-selected", "true")
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
await expect(tree).toBeVisible()
const expand = async (name: string) => {
const folder = tree.getByRole("button", { name, exact: true }).first()
await expect(folder).toBeVisible()
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
await expect(folder).toHaveAttribute("aria-expanded", "true")
}
await expand("packages")
await expand("app")
await expand("src")
await expand("components")
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
await expect(file).toBeVisible()
await file.click()
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")
})

View File

@@ -0,0 +1,156 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})

View File

@@ -0,0 +1,105 @@
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <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 = {
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
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 seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect }

View File

@@ -0,0 +1,48 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
await expect(selected).toBeVisible()
const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
const target = (await other.count()) > 0 ? other : selected
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const model = key.split(":").slice(1).join(":")
await input.fill(model)
await clickListItem(dialog, { key })
await expect(dialog).toHaveCount(0)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
})

View File

@@ -0,0 +1,61 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings, clickListItem } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})

View File

@@ -0,0 +1,43 @@
import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
const open = async () => {
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
})
})

View File

@@ -0,0 +1,54 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -0,0 +1,143 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
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 }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory }) => {
await defocus(page)
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
// 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
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -0,0 +1,109 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
async function waitWorkspaceReady(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 createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen])
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
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
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("")
const sessionID = sessionIDFromUrl(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 }
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return ""
return info.directory
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, 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)
})
})

View File

@@ -0,0 +1,373 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
test.describe.configure({ mode: "serial" })
import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
slugFromUrl,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)
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)
return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
})
test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir)
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
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)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
})

View File

@@ -0,0 +1,95 @@
import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
})
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})

View File

@@ -0,0 +1,76 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})

View File

@@ -0,0 +1,22 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})

View File

@@ -0,0 +1,30 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})

View File

@@ -0,0 +1,181 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
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, draft)
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const 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 prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
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, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})

View File

@@ -0,0 +1,26 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
await page.keyboard.type(`@${file}`)
const suggestion = page.getByRole("button", { name: filePattern }).first()
await expect(suggestion).toBeVisible()
await suggestion.hover()
await page.keyboard.press("Tab")
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", filePattern)
await page.keyboard.type(" ok")
await expect(page.locator(promptSelector)).toContainText("ok")
})

View File

@@ -0,0 +1,18 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})

View File

@@ -0,0 +1,62 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})

View File

@@ -0,0 +1,22 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})

View File

@@ -0,0 +1,64 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})

View File

@@ -0,0 +1,21 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View File

@@ -0,0 +1,55 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
const onPageError = (err: Error) => {
pageErrors.push(err.message)
}
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 {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
}
})

View File

@@ -0,0 +1,72 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
export const settingsFontSelector = '[data-action="settings-font"]'
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`

View File

@@ -0,0 +1,37 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
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)
}
})
})

View File

@@ -0,0 +1,425 @@
import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await fn(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) {
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) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: () => Promise<T>,
) {
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
try {
return await fn()
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
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()
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await 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 () => {
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 permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await 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 () => {
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, /deny/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 permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await 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 () => {
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 always/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("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
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()
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async () => {
await page.goto(page.url())
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 page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
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: "second task", status: "cancelled", priority: "medium" },
],
})
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)
})
})
})

View File

@@ -0,0 +1,233 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined
await expect
.poll(
async () => {
const users = (await messages()).filter(
(m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
)
if (users.length === 0) return false
const user = users[users.length - 1]
if (!user) return false
userMessageID = user.info.id
return true
},
{ timeout: 90_000, intervals: [250, 500, 1_000] },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const first = await seedConversation({
page,
sdk,
sessionID: session.id,
token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})

View File

@@ -0,0 +1,174 @@
import { test, expect } from "../fixtures"
import {
openSidebar,
openSessionMoreMenu,
clickMenuItem,
confirmDialog,
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
type Sdk = Parameters<typeof withSession>[0]
async function seedMessage(sdk: Sdk, sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
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).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
})

View File

@@ -0,0 +1,389 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyH`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("H")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
await closeDialog(page, dialog)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(finalClosed).toBe(initiallyClosed)
})
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyP`)
await page.waitForTimeout(100)
const toast = page.locator('[data-component="toast"]').last()
await expect(toast).toBeVisible()
await expect(toast).toContainText(/already/i)
await keybindButton.click()
await expect(keybindButton).toContainText("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
})
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const customKeybind = await keybindButton.textContent()
expect(customKeybind).toContain("X")
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
await expect(resetButton).toBeVisible()
await expect(resetButton).toBeEnabled()
await resetButton.click()
await page.waitForTimeout(100)
const restoredKeybind = await keybindButton.textContent()
expect(restoredKeybind).toContain("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("clearing a keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press("Delete")
await page.waitForTimeout(100)
const clearedKeybind = await keybindButton.textContent()
expect(clearedKeybind).toMatch(/unassigned|press/i)
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+B`)
await page.waitForTimeout(100)
const stillOnSession = page.url().includes("/session")
expect(stillOnSession).toBe(true)
})
test("changing settings open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain(",")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("/")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
await closeDialog(page, dialog)
const settingsDialog = page.getByRole("dialog")
await expect(settingsDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
await expect(settingsDialog).toBeVisible()
await closeDialog(page, settingsDialog)
})
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session for keybind", async (session) => {
await gotoSession(session.id)
const initialUrl = page.url()
expect(initialUrl).toContain(`/session/${session.id}`)
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyN`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("N")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Shift+N`)
await page.waitForTimeout(200)
const newUrl = page.url()
expect(newUrl).toMatch(/\/session\/?$/)
expect(newUrl).not.toContain(session.id)
})
})
test("changing file open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyF`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("F")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
await closeDialog(page, dialog)
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
await expect(filePickerDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+F`)
await page.waitForTimeout(100)
await expect(filePickerDialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(filePickerDialog).toHaveCount(0)
})
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+KeyY`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("Y")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
await closeDialog(page, dialog)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyY`)
await page.waitForTimeout(100)
await expect(keybindButton).toContainText("Y")
await closeDialog(page, dialog)
await page.reload()
await expect
.poll(async () => {
return await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
if (!raw) return
const parsed = JSON.parse(raw)
return parsed?.keybinds?.["terminal.toggle"]
})
})
.toBe("mod+shift+y")
const reloaded = await openSettings(page)
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
await expect(reloadedKeybind).toContainText("Y")
await closeDialog(page, reloaded)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyK`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("K")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
await closeDialog(page, dialog)
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
await expect(palette).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+K`)
await page.waitForTimeout(100)
await expect(palette).toBeVisible()
await expect(palette.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(palette).toHaveCount(0)
})

View File

@@ -0,0 +1,122 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "true")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})

View File

@@ -0,0 +1,136 @@
import { test, expect } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await expect(customProviderSection).toBeVisible()
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
await connectButton.click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("test-provider")
await providerDialog.getByLabel("Display name").fill("Test Provider")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
await providerDialog.getByLabel("API key").fill("fake-key")
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
await providerDialog.getByLabel("Base URL").fill("not-a-url")
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
await providerDialog.getByRole("button", { name: "Add model" }).click()
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
expect(idInputsAfter).toBe(idInputsBefore + 1)
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("header-test")
await providerDialog.getByLabel("Display name").fill("Header Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
await providerDialog.getByRole("button", { name: "Add header" }).click()
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})

View File

@@ -0,0 +1,519 @@
import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
settingsColorSchemeSelector,
settingsFontSelector,
settingsLanguageSelectSelector,
settingsNotificationsAgentSelector,
settingsNotificationsErrorsSelector,
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
await closeDialog(page, dialog)
})
test("changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
const dialog = await openSettings(page)
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
const select = dialog.locator(settingsLanguageSelectSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
await expect(heading).toHaveText("Allgemein")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
await expect(heading).toHaveText("General")
})
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsColorSchemeSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(colorScheme).toBe("dark")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
const lightColorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(lightColorScheme).toBe("light")
})
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
const storedThemeId = await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
expect(dataTheme).toBe(storedThemeId)
})
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
})
.toBe("oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-light")
})
})
.toBeNull()
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-dark")
})
})
.toBeNull()
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsFontSelector)
await expect(select).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(initialFontFamily).toContain("IBM Plex Mono")
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
await page.waitForTimeout(100)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
const newFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const fontSelect = dialog.locator(settingsFontSelector)
await expect(fontSelect).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const currentFont =
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
const fontItems = page.locator('[data-slot="select-select-item"]')
expect(await fontItems.count()).toBeGreaterThan(1)
if (currentFont) {
await fontItems.filter({ hasNotText: currentFont }).first().click()
}
if (!currentFont) {
await fontItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: expect.any(String),
},
})
const updatedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(updatedFontFamily).not.toBe(initialFontFamily)
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
await closeDialog(page, dialog)
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: updatedSettings?.appearance?.font,
},
})
const rehydratedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
await expect
.poll(async () => {
return await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
})
.not.toBe(initialFontFamily)
const rehydratedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.agent).toBe(false)
})
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.permissions).toBe(false)
})
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(false)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(true)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.errors).toBe(true)
})
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("selecting none disables agent sound", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(trigger).toBeEnabled()
await trigger.click()
const items = page.locator('[data-slot="select-select-item"]')
await expect(items.first()).toBeVisible()
await items.first().click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
await expect(permissionsSelect).toBeVisible()
await expect(errorsSelect).toBeVisible()
const initial = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const permissionsCurrent =
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
const permissionItems = page.locator('[data-slot="select-select-item"]')
expect(await permissionItems.count()).toBeGreaterThan(1)
if (permissionsCurrent) {
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
}
if (!permissionsCurrent) {
await permissionItems.nth(1).click()
}
const errorsCurrent =
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
const errorItems = page.locator('[data-slot="select-select-item"]')
expect(await errorItems.count()).toBeGreaterThan(1)
if (errorsCurrent) {
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
}
if (!errorsCurrent) {
await errorItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
sounds: {
permissions: expect.any(String),
errors: expect.any(String),
},
})
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
if (isDisabled) {
test.skip()
return
}
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.updates?.startup).toBe(false)
})
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.general?.releaseNotes).toBe(false)
})

View File

@@ -0,0 +1,39 @@
import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(oneItem).toBeVisible()
await expect(twoItem).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(twoItem).toBeVisible()
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})

View File

@@ -0,0 +1,30 @@
import { test, expect } from "../fixtures"
import { cleanupSession, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await openSidebar(page)
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})

View File

@@ -0,0 +1,40 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar, withSession } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "sidebar persist session 1", async (session1) => {
await withSession(sdk, "sidebar persist session 2", async (session2) => {
await gotoSession(session1.id)
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await gotoSession(session2.id)
await expect(button).toHaveAttribute("aria-expanded", "false")
await page.reload()
await expect(button).toHaveAttribute("aria-expanded", "false")
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
})
})

View File

@@ -0,0 +1,94 @@
import { test, expect } from "../fixtures"
import { openStatusPopover } from "../actions"
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
await expect(serversTab).toHaveAttribute("aria-selected", "true")
const serverList = popoverBody.locator('[role="tabpanel"]').first()
await expect(serverList.locator("button").first()).toBeVisible()
})
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
await mcpTab.click()
const ariaSelected = await mcpTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(mcpContent).toBeVisible()
})
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
await lspTab.click()
const ariaSelected = await lspTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(lspContent).toBeVisible()
})
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
await pluginsTab.click()
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(pluginsContent).toBeVisible()
})
test("status popover closes on escape", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
await expect(popoverBody).toHaveCount(0)
})

View File

@@ -0,0 +1,28 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
await page.keyboard.press(terminalToggleKey)
}
await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
// to the app shell before triggering `terminal.new`.
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
})

View File

@@ -0,0 +1,139 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
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) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await run(page, `echo ${two}`)
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toEqual({ first: true, second: false })
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})

View File

@@ -0,0 +1,17 @@
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
const terminal = page.locator(terminalSelector)
const initiallyOpen = await terminal.isVisible()
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
})

View File

@@ -0,0 +1,25 @@
import { test, expect } from "./fixtures"
import { modelVariantCycleSelector } from "./selectors"
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
await gotoSession()
await page.addStyleTag({
content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
})
const button = page.locator(modelVariantCycleSelector)
const exists = (await button.count()) > 0
test.skip(!exists, "current model has no variants")
if (!exists) return
await expect(button).toBeVisible()
const before = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(before)
const after = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(after)
})

View File

@@ -1,11 +0,0 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)

View File

@@ -2,7 +2,6 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]

63
packages/app/e2e/utils.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
const localHosts = ["127.0.0.1", "localhost"]
const serverLabels = (() => {
const url = new URL(serverUrl)
if (!localHosts.includes(url.hostname)) return [serverName]
return localHosts.map((host) => `${host}:${url.port}`)
})()
export const serverNames = [...new Set(serverLabels)]
export const serverUrls = serverNames.map((name) => `http://${name}`)
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree
}
export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
export function workspacePersistKey(directory: string, key: string) {
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}

View File

@@ -2,7 +2,7 @@
<html lang="en" style="background-color: var(--background-base)">
<head>
<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>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.4",
"version": "1.2.24",
"description": "",
"type": "module",
"exports": {
@@ -15,18 +15,17 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -46,21 +45,17 @@
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -6,12 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
@@ -23,8 +17,7 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,

View File

@@ -0,0 +1,179 @@
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
async function freePort() {
return await 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)
return
}
resolve(address.port)
})
})
})
}
async function waitForHealth(url: string) {
const timeout = Date.now() + 120_000
const errors: string[] = []
while (Date.now() < timeout) {
const result = await fetch(url)
.then((r) => ({ ok: r.ok, error: undefined }))
.catch((error) => ({
ok: false,
error: error instanceof Error ? error.message : String(error),
}))
if (result.ok) return
if (result.error) errors.push(result.error)
await new Promise((r) => setTimeout(r, 250))
}
const last = errors.length ? ` (last error: ${errors[errors.length - 1]})` : ""
throw new Error(`Timed out waiting for server health: ${url}${last}`)
}
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const extraArgs = (() => {
const args = process.argv.slice(2)
if (args[0] === "--") return args.slice(1)
return args
})()
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
const serverEnv = {
...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_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
} satisfies Record<string, string>
const runnerEnv = {
...serverEnv,
PLAYWRIGHT_SERVER_HOST: "127.0.0.1",
PLAYWRIGHT_SERVER_PORT: String(serverPort),
VITE_OPENCODE_SERVER_HOST: "127.0.0.1",
VITE_OPENCODE_SERVER_PORT: String(serverPort),
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
const cleanup = async () => {
if (cleaned) return
cleaned = true
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
const jobs = [
inst?.Instance.disposeAll(),
server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
}
const shutdown = (code: number, reason: string) => {
process.exitCode = code
void cleanup().finally(() => {
console.error(`e2e-local shutdown: ${reason}`)
process.exit(code)
})
}
const reportInternalError = (reason: string, error: unknown) => {
console.warn(`e2e-local ignored server error: ${reason}`)
console.warn(error)
}
process.once("SIGINT", () => shutdown(130, "SIGINT"))
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
process.once("uncaughtException", (error) => {
reportInternalError("uncaughtException", error)
})
process.once("unhandledRejection", (error) => {
reportInternalError("unhandledRejection", error)
})
let code = 1
try {
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
const seedExit = await seed.exited
if (seedExit !== 0) {
code = seedExit
} else {
Object.assign(process.env, serverEnv)
process.env.AGENT = "1"
process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation")
await log.Log.init({
print: true,
dev: install.Installation.isLocal(),
level: "WARN",
})
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir,
env: runnerEnv,
stdout: "inherit",
stderr: "inherit",
})
code = await runner.exited
}
} catch (error) {
console.error(error)
code = 1
} finally {
await cleanup()
}
process.exit(code)

View File

@@ -1,63 +1,50 @@
import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
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 { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
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 { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
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 { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { Dynamic } from "solid-js/web"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => (
<SessionProviders>
<Session />
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
@@ -65,7 +52,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
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 {
@@ -75,15 +62,12 @@ declare global {
deepLinks?: string[]
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
}
}
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
function MarkedProviderWithNativeParser(props: ParentProps) {
const platform = usePlatform()
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function AppShellProviders(props: ParentProps) {
@@ -121,33 +105,25 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
<Suspense fallback={<Loading />}>
{props.appChildren}
{props.children}
</Suspense>
{props.appChildren}
{props.children}
</AppShellProviders>
)
}
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider locale={props.locale}>
<ThemeProvider>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
<DialogProvider>
<MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>
@@ -156,115 +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(
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) {
const server = useServer()
return (
@@ -279,32 +146,25 @@ export function AppInterface(props: {
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
return (
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate>
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
)
}

View File

@@ -1,9 +1,7 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
type Mem = Performance & {
memory?: {
@@ -29,17 +27,17 @@ type Obs = PerformanceObserverInit & {
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
@@ -56,7 +54,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
<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,
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
@@ -76,7 +74,6 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
}
export function DebugBar() {
const language = useLanguage()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -101,15 +98,14 @@ export function DebugBar() {
},
})
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()
if (value === undefined) return "n/a"
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()))
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
let prev = ""
let start = 0
@@ -350,87 +346,89 @@ export function DebugBar() {
syncHeap()
start()
makeEventListener(document, "visibilitychange", vis)
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]"
aria-label="Development performance diagnostics"
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
label="NAV"
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
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)}`}
label="FPS"
tip="Rolling frames per second over the last 5 seconds."
value={state.fps === undefined ? "n/a" : `${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()}
label="FRM"
tip="Worst frame time over the last 5 seconds."
value={time(state.gap)}
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}`}
label="JNK"
tip="Frames over 32ms in the last 5 seconds."
value={state.jank === undefined ? "n/a" : `${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() })}
label="LNG"
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
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()}
label="DLY"
tip="Worst observed input delay in the last 5 seconds."
value={time(state.delay)}
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()}
label="INP"
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
value={time(state.inp)}
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)}
label="CLS"
tip="Cumulative layout shift for the current app lifetime."
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label={language.t("debugBar.mem.label")}
label="MEM"
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(),
})
? "Used JS heap vs heap limit. Chromium only."
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
}
value={heapv()}
bad={bad(heap(), 0.8)}

View File

@@ -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 { useDialog } from "@opencode-ai/ui/context/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 { TextField } from "@opencode-ai/ui/text-field"
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 { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage()
const providers = useProviders()
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -40,41 +36,26 @@ export function DialogConnectProvider(props: { provider: string }) {
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.all.find((x) => x.id === props.provider)!,
globalSync.data.provider_auth[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({
methodIndex: undefined as undefined | number,
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,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
@@ -96,11 +77,6 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
@@ -144,7 +120,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback
}
async function selectMethod(index: number, inputs?: Record<string, string>) {
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
@@ -154,10 +130,6 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
@@ -165,7 +137,6 @@ export function DialogConnectProvider(props: { provider: string }) {
{
providerID: props.provider,
method: index,
inputs,
},
{ 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
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -321,12 +172,8 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
onMount(() => {
if (methods().length === 1) {
auto = true
selectMethod(0)
}
})
@@ -344,7 +191,7 @@ export function DialogConnectProvider(props: { provider: string }) {
function goBack() {
if (methods().length === 1) {
all()
dialog.show(() => <DialogSelectProvider />)
return
}
if (store.authorization) {
@@ -355,7 +202,7 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.reset" })
return
}
all()
dialog.show(() => <DialogSelectProvider />)
}
function MethodSelection() {
@@ -454,7 +301,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")}
{language.t("common.submit")}
</Button>
</form>
</div>
@@ -467,6 +314,12 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -515,7 +368,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")}
{language.t("common.submit")}
</Button>
</form>
</div>
@@ -533,6 +386,10 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
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 onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<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}>
<MethodSelection />
</Match>
@@ -621,9 +470,6 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">

View File

@@ -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: {} })

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