mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f283e3c20 | ||
|
|
a09f9a2dce | ||
|
|
ebaf31a7ce | ||
|
|
d88046fb1c |
No files matched your search
@@ -49,11 +49,8 @@ runs:
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# On Windows, extracting the cache archive takes longer than Bun downloading and
|
||||
# extracting the same packages itself, so Windows installs skip the cache.
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
if: runner.os != 'Windows'
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
@@ -64,11 +61,19 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
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 --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Save Bun dependencies
|
||||
if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
|
||||
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: models-snapshot
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0,12 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: dev
|
||||
|
||||
# The refresh script only needs `fetch`; skip the monorepo install.
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
|
||||
- name: Setup git committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Refresh snapshot
|
||||
run: bun packages/core/script/update-models-snapshot.ts
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FILE="packages/core/src/models-dev/snapshot.txt"
|
||||
|
||||
if [ -z "$(git status --short -- "$FILE")" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add "$FILE"
|
||||
git commit -m "chore(core): refresh bundled models.dev snapshot"
|
||||
|
||||
git pull --rebase --autostash origin dev
|
||||
git push origin HEAD:dev --no-verify
|
||||
|
||||
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -6,7 +6,7 @@ permissions:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, beta]
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
|
||||
+133
-256
@@ -4,8 +4,11 @@ run-name: "${{ format('release {0}', inputs.bump) }}"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
@@ -31,8 +34,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
# Desktop jobs poll this run's artifacts and jobs while the CLI builds.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'dev' && !inputs.bump && !inputs.version && 'dev') || '' }}
|
||||
@@ -43,6 +44,8 @@ jobs:
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -53,6 +56,10 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Install OpenCode
|
||||
if: inputs.bump || inputs.version
|
||||
run: bun i -g opencode-ai
|
||||
|
||||
- id: version
|
||||
run: |
|
||||
./script/version.ts
|
||||
@@ -60,58 +67,19 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_VERSION: ${{ inputs.version }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
repo: ${{ steps.version.outputs.repo }}
|
||||
|
||||
# build-cli and build-node-cli embed the same web UI archive, so it builds once instead of in every CLI target's job.
|
||||
build-app-archive:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
# Each target builds in its own job and artifact, so signers and desktop jobs download only the CLIs they use.
|
||||
build-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-app-archive
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- darwin-arm64
|
||||
- darwin-x64
|
||||
- darwin-x64-baseline
|
||||
- windows-arm64
|
||||
- windows-x64
|
||||
- windows-x64-baseline
|
||||
- linux-arm64
|
||||
- linux-x64
|
||||
- linux-x64-baseline
|
||||
- linux-arm64-musl
|
||||
- linux-x64-musl
|
||||
- linux-x64-baseline-musl
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -121,42 +89,59 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
name: opencode-app-archive
|
||||
path: packages/cli/.cache
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build CLI
|
||||
run: ./packages/cli/script/build.ts --target=opencode-${{ matrix.target }} --app-archive=.cache/app-archive.bin
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- name: Build preview CLI
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
# The artifact must hold this target alone: publish merges every CLI artifact into one dist.
|
||||
- name: Check CLI output
|
||||
run: |
|
||||
actual=$(ls packages/cli/dist)
|
||||
if [ "$actual" != "cli-${{ matrix.target }}" ]; then
|
||||
echo "Expected only cli-${{ matrix.target }} in packages/cli/dist, found: $actual"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
packages/opencode/dist/opencode-darwin*
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
|
||||
# The glob keeps the cli-<target>/ directory in the artifact.
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: cli-unsigned-${{ matrix.target }}
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-macos:
|
||||
needs: build-cli
|
||||
runs-on: macos-26
|
||||
# Beta publishes only the desktop app, which signs the CLI it bundles.
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -168,9 +153,8 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: cli-unsigned-darwin-*
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Sign macOS CLI binaries
|
||||
run: |
|
||||
@@ -204,15 +188,37 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: cli-signed-darwin
|
||||
path: packages/cli/dist/cli-darwin-*
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-app-archive:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-app-archive
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -245,7 +251,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-app-archive
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache
|
||||
|
||||
- name: Build
|
||||
@@ -268,9 +274,9 @@ jobs:
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'beta')
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -279,11 +285,12 @@ jobs:
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: cli-unsigned-windows-*
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
@@ -330,15 +337,15 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: cli-signed-windows
|
||||
path: packages/cli/dist/cli-windows-*
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-electron:
|
||||
# Desktop packaging re-signs the bundled CLI on macOS and Windows, so it starts from the unsigned build.
|
||||
# It builds the app while build-cli runs and waits for its own CLI just before Prepare.
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
if: github.repository == 'anomalyco/opencode' && ((github.ref_name == 'dev' && needs.version.outputs.release != '') || github.ref_name == 'beta')
|
||||
continue-on-error: false
|
||||
env:
|
||||
@@ -351,39 +358,38 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# `cli` must be the build-cli target that packages/desktop/scripts/utils.ts bundles for `target`.
|
||||
settings:
|
||||
- host: macos-26
|
||||
- host: macos-26-intel
|
||||
target: x86_64-apple-darwin
|
||||
cli: darwin-x64-baseline
|
||||
platform_flag: --mac --x64
|
||||
bun_install_flags: --os=darwin --cpu=*
|
||||
bun_install_flags: --os=darwin --cpu=x64
|
||||
- host: macos-26
|
||||
target: aarch64-apple-darwin
|
||||
cli: darwin-arm64
|
||||
platform_flag: --mac --arm64
|
||||
bun_install_flags: --os=darwin --cpu=arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
cli: windows-arm64
|
||||
platform_flag: --win --arm64
|
||||
bun_install_flags: --cpu=*
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
cli: windows-x64-baseline
|
||||
platform_flag: --win
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: x86_64-unknown-linux-gnu
|
||||
cli: linux-x64-baseline
|
||||
platform_flag: --linux
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404-arm"
|
||||
target: aarch64-unknown-linux-gnu
|
||||
cli: linux-arm64
|
||||
platform_flag: --linux --arm64
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
@@ -399,6 +405,14 @@ jobs:
|
||||
with:
|
||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -427,10 +441,17 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
# The app builds before its CLI exists. `bun run build` would run the prebuild hook, which copies the CLI;
|
||||
# Prepare runs it once the CLI is here.
|
||||
- name: Build app
|
||||
run: bun run electron-vite build
|
||||
- name: Prepare
|
||||
run: bun ./scripts/prepare.ts
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
@@ -444,72 +465,10 @@ jobs:
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
# The job name is the one GitHub gives the build-cli leg for this target.
|
||||
- name: Wait for the CLI
|
||||
run: bun ./script/wait-for-artifact.ts cli-unsigned-${{ matrix.settings.cli }} "build-cli (${{ matrix.settings.cli }})"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: cli-unsigned-${{ matrix.settings.cli }}
|
||||
path: packages/cli/dist
|
||||
|
||||
# az requests new Azure tokens with the GitHub OIDC token, which expires 5 minutes after login. Prepare requests
|
||||
# the first signing token, so a login before a long wait would fail it with AADSTS700024.
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Prepare
|
||||
run: bun ./scripts/prepare.ts
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
# A transient download, signing timestamp or notarization error in one leg fails the whole release, so a failed
|
||||
# Package retries once below. Keep the retry identical to this step.
|
||||
- name: Package
|
||||
id: package
|
||||
if: needs.version.outputs.release
|
||||
continue-on-error: true
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||
working-directory: packages/desktop
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
CSC_KEYCHAIN: build.keychain
|
||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
|
||||
# Only dist holds electron-builder output. It copies out/ (the app build) and resources/ (the CLI from Prepare)
|
||||
# into dist rather than changing them, so the retry reuses both.
|
||||
- name: Clean up failed Package
|
||||
if: steps.package.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::Package failed for ${{ matrix.settings.target }}; retrying once in 90 seconds"
|
||||
rm -rf packages/desktop/dist
|
||||
if [ "$RUNNER_OS" = "macOS" ]; then
|
||||
for volume in /Volumes/OpenCode*; do
|
||||
[ -d "$volume" ] && hdiutil detach "$volume" -force || true
|
||||
done
|
||||
fi
|
||||
sleep 90
|
||||
|
||||
- name: Package (retry)
|
||||
if: steps.package.outcome == 'failure'
|
||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||
working-directory: packages/desktop
|
||||
timeout-minutes: 60
|
||||
@@ -568,23 +527,10 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# publish merges these artifacts into OPENCODE_DESKTOP_DIST, and packages/desktop/scripts/publish.ts ships only
|
||||
# its top-level *.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz} files. @actions/glob has no brace expansion,
|
||||
# so keep one line per extension in step with that glob. The installers are already compressed.
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-desktop-${{ matrix.settings.target }}
|
||||
path: |
|
||||
packages/desktop/dist/*.exe
|
||||
packages/desktop/dist/*.blockmap
|
||||
packages/desktop/dist/*.dmg
|
||||
packages/desktop/dist/*.zip
|
||||
packages/desktop/dist/*.AppImage
|
||||
packages/desktop/dist/*.deb
|
||||
packages/desktop/dist/*.rpm
|
||||
packages/desktop/dist/*.app.tar.gz
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
path: packages/desktop/dist/*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.version.outputs.release
|
||||
@@ -592,45 +538,15 @@ jobs:
|
||||
name: latest-yml-${{ matrix.settings.target }}
|
||||
path: packages/desktop/dist/latest*.yml
|
||||
|
||||
# Builds and packs the npm libraries while the CLI builds, so publish only publishes them. It gets no secrets or
|
||||
# id-token, so it cannot publish anything. Beta publishes no npm packages.
|
||||
pack-npm:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
# The same Bun and install as publish, so the tarballs match the ones publish would build.
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Pack npm libraries
|
||||
run: ./script/pack-npm.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_NPM_PACK_DIR: /tmp/npm-libraries
|
||||
|
||||
# The tarballs are already gzipped.
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: npm-libraries
|
||||
path: /tmp/npm-libraries/*.tgz
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-app-archive
|
||||
- build-node-app-archive
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
- pack-npm
|
||||
if: always() && !failure() && !cancelled()
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -638,9 +554,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
# Only releases push the Docker image (packages/cli/script/publish.ts).
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.version.outputs.release
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -648,11 +562,9 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
if: needs.version.outputs.release
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: needs.version.outputs.release
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -661,18 +573,27 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: cli-unsigned-linux-*
|
||||
path: packages/cli/dist
|
||||
merge-multiple: true
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: cli-signed-*
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'dev' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.build-node-cli.result == 'success'
|
||||
@@ -681,46 +602,6 @@ jobs:
|
||||
path: packages/cli/dist/node
|
||||
merge-multiple: true
|
||||
|
||||
# packages/cli/script/publish.ts publishes every package in dist, and a pattern that matches nothing downloads
|
||||
# nothing, so check that dist holds each target once and that no macOS binary is unsigned.
|
||||
- name: Check CLI packages
|
||||
if: github.ref_name != 'beta'
|
||||
working-directory: packages/cli/dist
|
||||
run: |
|
||||
expected="cli-darwin-arm64 cli-darwin-x64 cli-darwin-x64-baseline cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-baseline cli-linux-x64-baseline-musl cli-linux-x64-musl cli-windows-arm64 cli-windows-x64 cli-windows-x64-baseline"
|
||||
actual=$(printf '%s\n' cli-* | LC_ALL=C sort | xargs)
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "Expected: $expected"
|
||||
echo "Found: $actual"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for dir in $expected; do
|
||||
binary="$dir/bin/opencode"
|
||||
case "$dir" in cli-windows-*) binary="$binary.exe" ;; esac
|
||||
if [ ! -f "$dir/package.json" ] || [ ! -s "$binary" ]; then
|
||||
echo "$dir needs package.json and a non-empty $binary"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Cross-compiled macOS binaries keep Bun's own, now invalid, Developer ID signature, so match the full name of
|
||||
# the certificate sign-cli-macos signs with.
|
||||
for binary in cli-darwin-*/bin/opencode; do
|
||||
if ! LC_ALL=C grep -aqF "Developer ID Application: Anomaly Innovations, Inc. (5NZ4Q7NXJ4)" "$binary"; then
|
||||
echo "$binary is not signed with the OpenCode Developer ID"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# script/publish.ts checks these tarballs before it publishes anything, and the library scripts publish them
|
||||
# instead of building again.
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: npm-libraries
|
||||
path: /tmp/npm-libraries
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
@@ -741,9 +622,7 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
# Only releases publish to AUR and Homebrew and make release commits, which use the git identity set below.
|
||||
- name: Cache apt packages (AUR)
|
||||
if: needs.version.outputs.release
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
@@ -752,7 +631,6 @@ jobs:
|
||||
${{ runner.os }}-apt-aur-
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
if: needs.version.outputs.release
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
@@ -770,13 +648,12 @@ jobs:
|
||||
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
LATEST_YML_DIR: /tmp/latest-yml
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
OPENCODE_NPM_TARBALLS: /tmp/npm-libraries
|
||||
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
+57
-118
@@ -25,14 +25,13 @@ jobs:
|
||||
name: affected packages
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
e2e: ${{ steps.packages.outputs.app }}
|
||||
app: ${{ steps.packages.outputs.app }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
@@ -49,8 +48,7 @@ jobs:
|
||||
echo "app=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Storybook hosts the component tests, but no app package depends on it.
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --filter=@opencode/storybook --output=json > affected.json
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
|
||||
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
|
||||
|
||||
unit:
|
||||
@@ -72,8 +70,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -96,6 +93,15 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
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
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
@@ -128,6 +134,32 @@ jobs:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build.ts --single --skip-install
|
||||
bun run script/service-smoke.ts
|
||||
|
||||
- name: Setup Node build runtime
|
||||
if: always()
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||
bun run script/service-smoke.ts --node
|
||||
|
||||
- name: Check generated client
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/client
|
||||
@@ -138,76 +170,9 @@ jobs:
|
||||
working-directory: services/www
|
||||
run: bun run check:generated
|
||||
|
||||
build:
|
||||
name: build (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build.ts --single --skip-install
|
||||
bun run script/service-smoke.ts
|
||||
|
||||
# Unit tests import workspace sources and never build the libraries they use, so this checks those builds. The
|
||||
# CLI and app closures hold every library a tested package depends on. The builds are tsc and Bun.build with no
|
||||
# platform-specific steps, and the libraries publish from Linux, so Windows skips them.
|
||||
- name: Build workspace libraries
|
||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
bun turbo build --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
|
||||
exit 0
|
||||
fi
|
||||
bun turbo build --affected --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node build runtime
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||
bun run script/service-smoke.ts --node
|
||||
|
||||
e2e-suite:
|
||||
name: e2e (${{ matrix.settings.name }}, ${{ matrix.suite.id }})
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: affected
|
||||
if: needs.affected.outputs.e2e == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -216,45 +181,40 @@ jobs:
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
# Playwright splits whole spec files across shards by test count, not duration. Every app-N
|
||||
# entry must use the same total, and the entries must cover 1..total. The service worker suite
|
||||
# reuses shard 2's production build. Each component suite runs once per OS in its own leg, so the
|
||||
# two suites' Storybook servers and test time are not added up on one runner.
|
||||
suite:
|
||||
- id: app-1
|
||||
shard: 1/2
|
||||
- id: app-2
|
||||
shard: 2/2
|
||||
- id: session-ui-components
|
||||
- id: app-components
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'dev' && github.head_ref != 'dev' }}
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Setup Bun
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Read Playwright version
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
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
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -262,47 +222,47 @@ jobs:
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
|
||||
- name: Install Playwright system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
if: env.E2E_ENABLED == 'true' && 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'
|
||||
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests against production build
|
||||
if: matrix.suite.shard
|
||||
run: bun --cwd packages/app test:e2e:built --shard=${{ matrix.suite.shard }}
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
run: bun --cwd packages/app test:e2e:built
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Verify service worker precaching and upgrades
|
||||
if: matrix.suite.shard == '2/2'
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Run session UI component tests
|
||||
if: matrix.suite.id == 'session-ui-components'
|
||||
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
|
||||
run: bun --cwd packages/session-ui test:components
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run app component tests
|
||||
if: matrix.suite.id == 'app-components'
|
||||
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
|
||||
run: bun --cwd packages/app test:components
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-${{ matrix.settings.name }}-${{ matrix.suite.id }}-${{ github.run_attempt }}
|
||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
@@ -312,24 +272,3 @@ jobs:
|
||||
packages/session-ui/component-tests/playwright-report
|
||||
packages/app/component-tests/test-results
|
||||
packages/app/component-tests/playwright-report
|
||||
|
||||
# Branch rules require one e2e check per OS. These keep those names and pass only when every
|
||||
# leg above passed, or when no leg was needed; a failing leg on either OS fails both checks.
|
||||
e2e:
|
||||
name: e2e (${{ matrix.os }})
|
||||
needs: [affected, e2e-suite]
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [linux, windows]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Check browser test results
|
||||
env:
|
||||
AFFECTED: ${{ needs.affected.result }}
|
||||
SUITES: ${{ needs.e2e-suite.result }}
|
||||
EXPECTED: ${{ needs.affected.outputs.e2e == 'true' && 'success' || 'skipped' }}
|
||||
run: |
|
||||
echo "affected packages: $AFFECTED, browser test legs: $SUITES (expected $EXPECTED)"
|
||||
test "$AFFECTED" = success && test "$SUITES" = "$EXPECTED"
|
||||
@@ -1,138 +0,0 @@
|
||||
---
|
||||
name: app-e2e-tests
|
||||
description: Use when writing, changing, reviewing, or debugging Playwright e2e tests in packages/app/e2e (regression keepers and user stories). Covers the mocked opencode server harness and the Bun static server, per-test isolation, page-load and route cost, the suite-wide service-worker block, races with first mount, hover, measurement, clock, dialogs and shortcuts, projects, shards and worker scheduling, rehearsing Windows timing locally, and diagnosing a CI flake from its artifacts. Not for Storybook component tests or e2e/performance benchmarks.
|
||||
---
|
||||
|
||||
# App e2e tests
|
||||
|
||||
Read `packages/app/e2e/AGENTS.md` and the Tests section of `packages/app/AGENTS.md` first. They define what to test, which locators to use, and how to assert. `packages/app/README.md` (E2E Testing) documents run modes and env vars. This skill covers what makes these tests slow or flaky in CI, which runs the suite as two shards per OS on 4-vCPU runners (`.github/workflows/test.yml:207-229`). Most races below were found on Windows.
|
||||
|
||||
All paths below are relative to `packages/app` unless they start with `packages/`, `services/` or `.github/`.
|
||||
|
||||
## What a test runs against
|
||||
|
||||
- **No opencode backend.** Each shard leg runs `bun --cwd packages/app test:e2e:built --shard=<n>/2` (`test.yml:274-279`). It builds once and starts one Bun static server, `e2e/utils/serve.ts`, on `127.0.0.1:${PLAYWRIGHT_PORT ?? 3000}`. All 5 workers share it (`playwright.config.ts:23-27`).
|
||||
- `serve.ts` keeps vite preview's SPA fallback rules. Do not move built e2e back to vite preview. On case-insensitive file systems it synchronously reads the whole `dist/_assets` directory (about 3,100 files) for every asset request, so every worker's page loads wait in line. The `e2e/performance` configs still use it to keep their baselines comparable.
|
||||
- **The API is an in-memory mock per page,** running in the test worker. `mockOpenCodeServer` (`e2e/utils/mock-server.ts:202`):
|
||||
- answers `page.route("**/api/**")` with an Effect HttpApi handler;
|
||||
- fakes `/api/event` SSE with an init-script `fetch` override;
|
||||
- serves PTY sockets through `routeWebSocket`.
|
||||
- **Unmocked routes fail the test** with a 501 and `Unmocked API request` (`mock-server.ts:403-411`). Add the route to `mock-server.ts` once instead of routing around it in a spec.
|
||||
- **Build pages with the helpers:**
|
||||
- `mockWorkspace`, `openSession`, `openDraft`, `openWorktreeDraft`, `mockRemoteServer`, `openSettings` (`e2e/utils/workspace.ts`);
|
||||
- `setupTimeline` (`e2e/utils/timeline.ts:209`) for streamed timelines, `mockServers` (`mock-server.ts:495`) for several origins;
|
||||
- `seed` (`e2e/utils/app.ts:61`) for storage, `holdRoute` (`app.ts:207`) to hold requests until you release them.
|
||||
- **Deliver events** with the mock's `push`, which buffers until the app connects, or with `installSseTransport` (`e2e/utils/sse-transport.ts:60`), whose `send` resolves on delivery. After `page.reload()`, await `transport.waitForConnection()` before sending (`e2e/regression/timeline-stream.spec.ts:1700-1702`).
|
||||
- **Windows-only failures are races, not Windows code paths.** Mock directories are `C:/OpenCode/<name>` on every OS (`workspace.ts:33`), and nothing touches the disk. Lazy chunks, stylesheets and responses finish out of order more often on the Windows runners.
|
||||
|
||||
## Isolation
|
||||
|
||||
- **No shared state between tests.** Each test gets a fresh context, mock handler and init scripts. Outside `e2e/service-worker`, which runs under its own config and shares builds through a worker fixture (`e2e/service-worker/cache.spec.ts:21`, `:74`), app e2e has no `beforeAll`, serial mode, `test.extend` or worker fixtures. Keep it that way so `-g` and `PLAYWRIGHT_FULLY_PARALLEL=1` keep working.
|
||||
- **Handlers mutate the objects you pass.** Creating a session pushes into `sessions` (`mock-server.ts:839`). Renaming writes `session.title` (`:1340`), and VCS init writes `project.vcs` (`:1019`).
|
||||
- Call `session()`/`project()` inside the test.
|
||||
- `mockWorkspace` and `openSession` copy what you pass (`workspace.ts:36-48`).
|
||||
- `mockOpenCodeServer` and `mockServers` use your objects as they are. Module-level fixtures like `multi-server.spec.ts:10-19` are therefore shared by every test in the file.
|
||||
- **Timeline builders keep state per test attempt** (`timeline.ts:165-198`). Call stateful builders such as `assistantMessage` or `partUpdated` inside the test. Only pure ones such as `toolPart` belong at module scope.
|
||||
- **Seed before the first `goto`.** `seed` writes storage once per tab behind a `sessionStorage` marker, so reloads keep what the app stored (`app.ts:59-90`). It cannot reset state mid-test.
|
||||
|
||||
## Keep it fast
|
||||
|
||||
- **Navigate once.** Every `goto` or `reload` is a cold load of the production bundle: about 150 requests per session open, about 125 of them static files. Each test has a new context, and any `page.route` disables the HTTP cache. Reach later states through the UI or through pushed events. Reload only when surviving a reload is the contract.
|
||||
- **Service workers are already blocked suite-wide** (`serviceWorkers: "block"`, `playwright.config.ts:66-67`). Do not add a per-file block; the 11 in `e2e/regression` are redundant.
|
||||
- Production registers `/sw.js` on load (`src/entry.tsx:54-55`) and precaches about 1,600 files (`globPatterns: ["**/*"]`, `vite.pwa.ts:34`). `e2e/service-worker/` covers it under its own config. The main config ignores that directory (`playwright.config.ts:33-36`), and CI runs it after shard 2 (`test.yml:281-285`).
|
||||
- Opt back in with `test.use({ serviceWorkers: "allow" })` only when the worker is the contract. It never controls the first load (`clientsClaim: false`, `vite.pwa.ts:26-28`). After a reload it can control the page, and page routes may then miss its requests.
|
||||
- Every built-mode page load logs Playwright's block warning. Console collectors must ignore it, as `captureConsoleWarnings` does (`e2e/utils/command-palette.ts:15-26`).
|
||||
- **Match new routes with a RegExp or a query-aware glob, not a function.**
|
||||
- A single function matcher makes Playwright intercept `**/*` for the page. Every static asset then round-trips to the test worker (250 continued requests in one Windows test).
|
||||
- Existing `holdRoute` calls such as `review.spec.ts:51` and `mockMissingLocation` (`mock-server.ts:480`) still use functions and pay this. Converting one route gains nothing while another function route is registered on the page.
|
||||
- Globs match the whole URL, so `**/api/vcs/init` misses `/api/vcs/init?location...`. Write `/\/api\/vcs\/init\?/` or `"**/api/session?*"` (`command-palette.spec.ts:10`, `:28`).
|
||||
- **Use the stress fixture only when scale is the contract.** `fixture`, `mockStressTimeline` and `installStressSessionTabs` (`e2e/utils/session-fixture.ts:336`, `:436`, `:448`) serve a long timeline with 300-line diffs.
|
||||
- Its cold mount reveals rows only after worker-rendered markdown arrives. Mounting one of its diffs blocked the renderer for 4-11 s on CI.
|
||||
- The 14 regression files that use it held about half of the e2e flakes CI found.
|
||||
- Prefer `openSession` or `setupTimeline` with the few messages the test needs.
|
||||
- **Assert inside loops only when each step is the contract.** A web-first assertion that fails its first check retries after 100, 250, 500, then 1,000 ms. The 10-step loop at `mobile-timeline-scroll.spec.ts:688-693` takes about 8 s, mostly backoff.
|
||||
- **Real-time waits cost their full length on every run and retry** (`settings-wsl.spec.ts:268-276` waits out a 10 s health check). Prefer `page.clock`:
|
||||
- install it before the `goto` or `reload` whose timers you need (`timeline-stream.spec.ts:1700`);
|
||||
- keep fast-forwards under the client's 45 s event-stream idle timeout (`packages/client/src/solid/connection.ts:35`).
|
||||
- **Leave benchmark pacing out of regression tests.** The `sendAll` delays and `messageDelay` exist for benchmarks (`timeline.ts:306`, `mock-server.ts:82`). Hold message pages with `beforeMessagesResponse` instead.
|
||||
|
||||
## Wait for state, not time: races CI found
|
||||
|
||||
Most of these rarely reproduce on an idle local machine.
|
||||
|
||||
- **First mount happens in stages.** `openSession` and `expectSessionTitle` return once the composer is editable and a title is visible. Later the loading header's `<h1>` is replaced, and a cold timeline keeps its rows hidden while it scrolls itself to the bottom. Before you measure or hover in the session view, wait for something that only the final mount renders, such as the last `[data-timeline-row="UserMessage"]` or the row you click next.
|
||||
- **Hovering during a scroll loses the tooltip.** A tooltip closes when its trigger scrolls, and Playwright's mouse never moves again. `session-summary.spec.ts:21-26` waits for the last row before it hovers; without that wait it needed a retry in 6 of 36 Windows runs.
|
||||
- **One-shot measurements can hit a detached node.** `locator.evaluate` resolves the element, then evaluates in a second round trip. If the node is replaced in between, `getBoundingClientRect` returns an all-zero rect and `boundingBox` returns `null`. The subagent title-anchor test failed with "Expected: 0" in 11 runs until it waited for a timeline-only button (`subagent-child-navigation.spec.ts:60-64`). Take reference measurements once the final structure is in place. Polled reads re-resolve and are safe.
|
||||
- **`goto` returns late, so a modal may already be open.**
|
||||
- `goto` waits for `load`, which can fire after the app is already interactive.
|
||||
- An open dialog sets `aria-hidden` on the app root, which hides everything behind it from role locators.
|
||||
- What's New opens 500 ms after the changelog response, and it raced the Settings check in 11 of 36 Windows runs. Hold the request that opens a modal until readiness is asserted, as `startUpgraded` does (`whats-new.spec.ts:227-234`).
|
||||
- **`pauseAt(new Date())` right after `install()` can throw** "Cannot fast-forward to the past", because the installed clock starts slightly ahead of the runner's clock. Install at an explicit time and pause at an offset from it (`tabs.spec.ts:443-445`).
|
||||
- **A shortcut that does nothing is usually a product guard, not timing.** The global handler ignores keys while `dialog.active` (`src/shell/commands/command.tsx:489`). `active` is the top dialog that has not started closing (`packages/ui/src/context/dialog.tsx:52`, `:310-311`). A dialog that has started closing does not block keys during its 100 ms exit (`dialog.tsx:87`), but any open dialog does, including one you did not expect. Check that guard before you add a wait.
|
||||
- **Shortcut tests fail on local macOS; that is not a regression.**
|
||||
- The Desktop Chrome profile reports a Windows UA and `navigator.platform = "Win32"`, so the app (`IS_MAC`, `command.tsx:14`) expects Ctrl. On macOS, `ControlOrMeta` sends Meta.
|
||||
- Every test that opens the palette through `openCommandPalette` (`e2e/utils/command-palette.ts:50`) fails locally and passes in CI, as do `tabs.spec.ts:835` and `timeline-cache.spec.ts:163`. Verify on Linux or in CI, or press `Control` in a throwaway copy. Do not edit tests for macOS.
|
||||
- A new test can pick the modifier from the page, as `session-summary.spec.ts:29-30` does.
|
||||
- **Font probes mislead.** `document.fonts.check()` can stay false after the font has loaded: when a lazy `@layer` stylesheet activates late, Chromium rebuilds font faces as unloaded. The code-font test failed outright in 9 of 36 Windows runs that way. Polling a probe that lays out text can make itself true. Await `document.fonts.ready`, lay out new text, and then check once (`timeline-cache.spec.ts:281-293`).
|
||||
- **Use the harness's signals.** `holdRoute(...).arrived` resolves when the request reaches the route, and `transport.send` resolves after delivery. `setupTimeline(...).waitForPart` resolves once a part renders (`timeline.ts:328`).
|
||||
- **Decide which side is wrong.** Three of eight investigated e2e flakes were product bugs, each since fixed in the product:
|
||||
- a stale scroll offset restored when a scroll settles (`src/session/timeline/observe-element-offset.ts`);
|
||||
- a video double-click that also entered native fullscreen (`packages/gui-extensions/src/file/artifact-view.tsx:617`);
|
||||
- shortcuts dropped while a dialog closed.
|
||||
|
||||
If a user could hit the interleaving, fix the product and keep the assertion.
|
||||
|
||||
## Scheduling
|
||||
|
||||
- **Two projects split the suite** (`playwright.config.ts:69-86`). `chromium-touch-scroll` runs only `mobile-timeline-scroll.spec.ts`, which the `touchScroll` regex (`:29`) matches, with `fullyParallel: true`.
|
||||
- Projects queue in declaration order, so its 37 tests start after every `chromium` file and spread across all workers. As one serial group, the file took 3-5 minutes in CI.
|
||||
- A rename that the regex misses quietly puts the file back in `chromium` as that serial group.
|
||||
- `--project chromium`, UI mode and the VS Code extension's default project skip the file.
|
||||
- **In `chromium`, files are the scheduling unit.** `fullyParallel` is off unless `PLAYWRIGHT_FULLY_PARALLEL=1` (`:42`), so a file's tests run in order on one worker. A long file sets a floor and leaves an idle tail. New regressions go into the area's keeper file (`e2e/AGENTS.md`), so keep each case cheap.
|
||||
- **Shards split whole files by test count, not duration** (`test.yml:219-222`). The touch project queues last, so its tests always fall in shard 2. To reproduce a leg's schedule, pass the same `--shard`.
|
||||
- **Worker-scoped options fork a worker.** `launchOptions`, `channel`, `headless`, `video`, `trace`, `screenshot` and `defaultBrowserType` give a file its own worker hash.
|
||||
- Playwright groups a project's files by worker hash in order of first appearance, so such a file runs after every default file in its project.
|
||||
- Use test-scoped options instead: `viewport`, `deviceScaleFactor`, `hasTouch`, `isMobile`, `userAgent`, `colorScheme`, `serviceWorkers`, `locale`.
|
||||
- Use `launchOptions` only when a Chromium flag is the contract, with a comment (`patch-border.spec.ts:5-9`).
|
||||
- **Never spread `devices[...]` into `test.use`.** iPhone entries set `defaultBrowserType: "webkit"`, which forks a worker and needs a browser that CI never installs (`test.yml:269-272`). Copy the fields you need, as `mobile-timeline-scroll.spec.ts:23-28` does.
|
||||
- **Retries are not free.** CI retries twice (`playwright.config.ts:44`). Each failure restarts the worker's browser and costs 10-30 s of cleanup on Windows. The first retry then reruns with a trace and a video (`:62-65`).
|
||||
- **Timeouts are not a fix.** The limits are 60 s per test and 10 s per assertion (`playwright.config.ts:38-41`). `APP_READY_TIMEOUT` (30 s, `e2e/utils/waits.ts:4`) covers only first readiness.
|
||||
- When one wait is genuinely long, scope the timeout to that assertion and say what takes the time. `settings-wsl.spec.ts:268-276` does this with `{ timeout: 20_000 }` for a 10 s health check.
|
||||
- `test.setTimeout` does not lengthen assertions. `timeline-history.spec.ts:73` sets 240 s without a comment and still fails at the 10 s poll at `:127`.
|
||||
|
||||
## Before you push
|
||||
|
||||
Run from `packages/app`:
|
||||
|
||||
- [ ] Run in built mode, as CI does: `bun run test:e2e:built -- e2e/regression/<file>.spec.ts`. Dev mode serves unbundled modules, with the API on another origin (`playwright.config.ts:19-21`).
|
||||
- [ ] Use a free port when another worktree or agent may hold 3000: `PLAYWRIGHT_PORT=4310 bun run test:e2e:built -- ...`. Built runs never reuse a server, and `serve.ts` fails on a busy port. Stay below 49152 on Windows: it reserves ports from there up and hands them out for outgoing connections (`services/www/src/docs/content/cli/web.mdx:43`, `packages/client/test/fixture/service-fixture.ts:108-109`).
|
||||
- [ ] To repeat without rebuilding:
|
||||
- build once with `VITE_OPENCODE_TEST_FIXTURES=1 bun run build`;
|
||||
- keep `bun e2e/utils/serve.ts --host 127.0.0.1 --port 4310` running;
|
||||
- run `PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4310 bun run test:e2e -- ...`.
|
||||
- [ ] Repeat new or changed tests at CI concurrency without retries: `--repeat-each=10 --workers=5 --retries=0 -g "<title>"`. Run the file once with `PLAYWRIGHT_FULLY_PARALLEL=1` to prove that order does not matter.
|
||||
- [ ] Rehearse Windows timing by delaying assets, not only the CPU. CPU throttling at 4x and 6x did not reproduce the tooltip or title-anchor races, but delayed responses did.
|
||||
- In a throwaway copy, hold the suspect lazy chunk, stylesheet or API response with `holdRoute`, and release it 0.6-1.5 s after `arrived`.
|
||||
- Route worker scripts with `page.context().route`.
|
||||
- A race fix must fail before the fix and pass after it (`packages/app/AGENTS.md`, Tests).
|
||||
- [ ] Check the cost on a noisy machine. Other agents add load, and local A/Bs at a load average of 20-35 were inconclusive.
|
||||
- Compare the medians of interleaved before/after runs at `--workers=5`, or the step durations in the report (`bun run test:e2e:report`).
|
||||
- Count every `goto` and `reload` you added.
|
||||
- [ ] `bun run typecheck:e2e` reports 4 existing errors: `timeline-stream.spec.ts:1822`, `:1825`, and two `e2e/performance` probes. Add none. If you changed `playwright.config.ts`, update `test-browser/playwright-config.test.ts`.
|
||||
|
||||
## When CI flakes
|
||||
|
||||
- **Find it.** e2e runs on pull requests that affect `@opencode/app` or `@opencode/storybook`, including their workspace dependencies, and on every manual dispatch (`.github/workflows/test.yml:42-54`). It never runs on dev (`:28`).
|
||||
- Each OS has the legs `app-1` and `app-2` (the shards), `session-ui-components` and `app-components`.
|
||||
- The required `e2e (linux)` and `e2e (windows)` checks only aggregate those legs (`:316-335`).
|
||||
- A pass on retry leaves a leg green. Check each Windows shard report's Flaky list, and make sure your new tests are not on it.
|
||||
- **Download:** `gh run download <run-id> -n playwright-windows-app-2-1 -D <dir>`. Artifacts are named `playwright-<os>-<leg>-<run attempt>` (`test.yml:301-314`). App e2e results are in `<dir>/app/e2e/`.
|
||||
- **In CI, the failing first attempt has no trace and no video** (`playwright.config.ts:62-65`).
|
||||
- `test-results/<test>-<project>/` holds `error-context.md` (the aria snapshot at the failure) and `test-failed-1.png`.
|
||||
- `<test>-<project>-retry1/` holds `trace.zip` and `video.webm` whether the retry passed or failed, plus failure files only if it failed. A second retry records neither.
|
||||
- **Read `error-context.md` first.** An open dialog or an element with no accessible name explains most "element(s) not found" errors.
|
||||
- **Get timings from the retry trace:** `bunx playwright show-trace <dir>/app/e2e/test-results/<test>-<project>-retry1/trace.zip`. To see which side won, compare when a screencast frame changed with when the response landed.
|
||||
- **Get the transient state from a failing video,** sampled frame by frame (`ffmpeg -i video.webm -vf fps=4 frame-%03d.png`). That is how the tooltip race showed the tooltip closing in the same frame the timeline rows appeared.
|
||||
- In CI, retry1's video shows the failure only if the retry failed too.
|
||||
- Locally, a failing test keeps its video (`retain-on-failure`), so you can record the race with `--repeat-each` and delayed assets.
|
||||
- **Open the report:** `bunx playwright show-report <dir>/app/e2e/playwright-report`. It shows step durations for every attempt, including passing ones.
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
name: component-tests
|
||||
description: Use when writing, changing, debugging, or reviewing Storybook-based Playwright component tests. That covers specs in packages/app/component-tests or packages/session-ui/component-tests, `*.fixture.tsx` files there or beside source (such as packages/gui-extensions/src/browser/panel.fixture.tsx), the stories they mount, and the shared harness in packages/storybook (playwright/config.ts, playwright/story.ts, .storybook/main.ts, preview.tsx, mocks). Also use when a component test is flaky, passed on retry, or fails with "Execution context was destroyed", "Failed to fetch dynamically imported module", or `#storybook-root` hidden. Covers mount args, source modules imported by URL, Vite dependency pre-bundling and reloads, cold vs warm Storybook, mobile drawers, geometry, and checks before pushing.
|
||||
---
|
||||
|
||||
# Component tests
|
||||
|
||||
Each package suite starts one Storybook/Vite dev server, and every worker shares it (`packages/storybook/playwright/config.ts:17-24`; CI uses 2 workers, `config.ts:15`). With `fullyParallel` (`config.ts:12`), the tests of one file run on several workers in any order. There is no backend: server, SDK and sync modules are in-page mocks. Every test gets a fresh context, and `mount` is a full iframe navigation (`packages/storybook/playwright/story.ts:37`). Slow or flaky component tests in CI have come from five sources: the shared server's dependency optimizer, module resolution that depended on the install layout, a cold first story, a product race (the mobile drawer), or an action that ran before the UI reached the state it needed.
|
||||
|
||||
Read these first rather than repeating them:
|
||||
|
||||
- `packages/storybook/playwright/README.md` covers layout, run commands and "Adding a test".
|
||||
- `packages/app/AGENTS.md` "Tests" decides whether a test should exist, when theme or direction cases are allowed, and that a regression test must fail before the fix.
|
||||
- `packages/app/e2e/AGENTS.md` "Test Hygiene" applies here unchanged.
|
||||
|
||||
## Stories and `mount`
|
||||
|
||||
- **Location.** Put the story next to the production component under `src/` of `ui`, `session-ui`, `app` or `gui-extensions`. Storybook indexes only the `stories` globs in `packages/storybook/.storybook/main.ts:44-49`.
|
||||
- **Story ID.** It is the meta `id` if one is set (`packages/session-ui/src/components/session-review.stories.tsx:25`). Otherwise it is the kebab-cased `title`, then `--`, then the kebab-cased export. Check it at `http://127.0.0.1:6006/?path=/story/<id>`. A wrong ID never renders, so `mount` waits out its full 30 s root wait (`story.ts:39`) and then fails.
|
||||
- **URL args can be dropped.** `mount(id, { args, globals })` writes them into the URL (`story.ts:20-36`). Storybook drops an arg when the story declares no `args`/`argTypes` entry for its key. It also drops one whose value is not `[A-Za-z0-9 _-]*`, a number, a hex color or `rgb()`/`hsl()`, which rules out paths, URLs and file names with dots. The only trace is the browser console warning "Omitted potentially unsafe URL args.", and the test then passes on the default scenario.
|
||||
- Give every arg a typed default. With `args: { split: false }` (`session-review.stories.tsx:129`), `split:false` arrives as a boolean.
|
||||
- Pick punctuated values inside the story, from a `scenario`-style arg.
|
||||
- **Globals** are `theme`, `direction` and `locale` (`packages/storybook/.storybook/preview.tsx:106-122`). `mount` also sets `a11y.manual` (`story.ts:29-36`); caller globals override it.
|
||||
- **Portaled UI.** `mount` returns `#storybook-root`. Dialogs, menus and drawers render outside it, so locate them from `page` (`packages/app/component-tests/composer.spec.ts:101-107`).
|
||||
- **Server state** comes from the `resolve.alias` list in `viteFinal` (`main.ts:72-94`), which points app server, SDK and sync modules at `packages/storybook/.storybook/mocks/app/**`. Extend a mock rather than adding a server. Restore mock state in `onCleanup` (`mockProviderAuth`, `packages/app/src/providers/connect/dialog.stories.tsx:23`) so that switching stories in the Storybook UI does not inherit it. Tests reset it on every mount.
|
||||
- **Requests the mocks do not answer.** Route them with `page.route`. That includes server endpoints such as `/api/fs/read` (`packages/session-ui/component-tests/read-image.spec.ts:20`) and external URLs such as favicons (`packages/session-ui/component-tests/markdown.spec.ts:217`).
|
||||
|
||||
## Fixtures imported by URL
|
||||
|
||||
Use these when the contract is not visible in a story, such as construction counts, unmount races or cache state.
|
||||
|
||||
- **Fixture file.** Put a `*.fixture.tsx` file in `component-tests/` or beside the source it drives (`packages/gui-extensions/src/browser/panel.fixture.tsx`). It exports a `mountX()` that renders into a host with a `data-testid` (`packages/session-ui/component-tests/basic-tool.fixture.tsx:7-46`). Never end the name in `.spec` or `.test`.
|
||||
- **Mount it in three steps.**
|
||||
1. Mount a light story first, so that the app styles load.
|
||||
2. Import the fixture and call it inside `page.evaluate`.
|
||||
3. Locate it with `page.getByTestId` (`packages/session-ui/component-tests/basic-tool.spec.ts:30-36`).
|
||||
|
||||
It renders outside the preview decorators, so it must supply its own providers (`packages/app/component-tests/browser-pane-restore.spec.ts:69-83`).
|
||||
|
||||
- **Module URLs.** Build each one with `sourceURL(new URL("<relative path with extension>", import.meta.url))` (`story.ts:10`), written as a string literal in the spec (`browser-pane-restore.spec.ts:6-24`). Any other spelling either fails to load or loads a second module instance whose contexts the page never provides; a hand-written `/@fs//...` is the documented second-instance case (`story.ts:6-9`). The literal also matters for pre-bundling (next section).
|
||||
- **Aliases.** A module imported by URL is the real file. App code that reaches the same module through a `main.ts` alias gets the mock (`browser-pane-restore.spec.ts:95-96`). Decide which of the two the contract needs.
|
||||
- **Spec imports.** Import source modules into the spec with `import type` only (`browser-pane-restore.spec.ts:2-4`). A value import runs browser code in the Node runner.
|
||||
- **Async order.** Let the test decide the order through deferreds the fixture exposes (`mountExtensionHost`, `packages/app/component-tests/extension-host.fixture.tsx:135`). Await the outcome they lead to (`extension-host.spec.ts:46-77`), or poll in-page state with `until` (`extension-host.fixture.tsx:124-132`). Do not copy the fixed in-page `setTimeout` waits that other cases in `extension-host.spec.ts` (`:38`, `:88-90`, `:108-119`) use to prove a negative.
|
||||
|
||||
## Vite pre-bundling and reloads
|
||||
|
||||
`viteFinal` drops `storybook:optimize-deps-plugin` (`main.ts:105-108`) and sets `optimizeDeps` itself (`main.ts:56-67`). Its `entries` are every `*.stories.tsx` and `*.worker.ts` under the four `src/` trees, every module a component spec names by a `./` or `../` string literal ending in `.ts`/`.tsx` (collected at `main.ts:15-29`), and `preview.tsx`; `include` adds the Storybook preview runtime. Vite pre-bundles their bare imports before the first page loads. A dependency the scan misses is found on first import instead. If that changes chunks already served, Vite logs `✨ optimized dependencies changed. reloading` and sends `full-reload` with path `*` to every page in every worker, and requests with a stale `?v=` hash get a 504 "Outdated Optimize Dep".
|
||||
|
||||
- **The bar is zero reloads.** A cold run of either suite prints neither `new dependencies optimized` nor `optimized dependencies changed`; neither did the Linux and Windows CI legs. Dropping `preview.tsx` and `include` from `main.ts` brings one reload back, so these lines are the signal to watch.
|
||||
- **Keep new code reachable by the scan.** A module path built at runtime, in a template literal, or without its extension is not collected, and neither is a spec named other than `*.spec.ts`. Name stories `*.stories.tsx`: Storybook indexes other extensions, but the scan reads only that one. Name workers `*.worker.ts`, since the scan does not follow `new Worker(new URL(…))`. Add an `optimizeDeps.include` entry only for a dependency the scan cannot reach. Vite names it in `✨ new dependencies optimized: …`, and `DEBUG=vite:deps` replaces that line with scan and discovery detail.
|
||||
- **Symptoms.** "page.evaluate: Execution context was destroyed", "Failed to fetch dynamically imported module …/@fs/…", or fixture state that is gone right after `mount` ("Registrations: 1" not found, an empty tab strip).
|
||||
- **Only `mount()` survives a reload,** because it navigates back to the same URL. A reload loses everything set up after it: `page.evaluate` imports, mounted fixtures, state from earlier clicks, and the elements your locators pointed at. Another worker's discovery can trigger it at any moment.
|
||||
- **Fix reloads in the scan, never with retries, timeouts or import order.** The import-everything-before-mount `Promise.all` in `browser-pane-restore.spec.ts:43-58` (comment at `:56`) only narrowed the window. Before the entries existed, that file's `:123` and `:150` were retried in 31 CI jobs each, and failed attempts cost about 39-47 s (Linux) and 34 s (Windows) of worker time per run.
|
||||
- **Clear the cache whenever entries matter.** Vite's `getConfigHash` covers `optimizeDeps.include`, `resolve` and plugin names, but not `entries`, and a valid `_metadata.json` skips the scan. A warm cache therefore never scans a new story or fixture, so it shows neither the reloads nor the absence of reloads that CI's cold scan would. Delete `packages/storybook/node_modules/.cache` before judging.
|
||||
- **Resolve through package resolution, not paths into `node_modules`.** `main.ts:69-71` dedupes `@tanstack/solid-query` instead of aliasing it into `app/node_modules`, a path that exists only under bun's isolated linker. While Windows CI installed hoisted, that alias made every app story fail there, and 23 of 30 runs hit the step's 15-minute cap. CI now runs the same `bun install --frozen-lockfile` on every OS (`.github/actions/setup-bun/action.yml:66-68`), so such an alias would pass CI and still break a hoisted local install.
|
||||
|
||||
## Cold vs warm, and what a mount costs
|
||||
|
||||
- **CI is always cold.** Each suite runs in its own leg on a fresh runner (`test.yml:228-229`), so every leg starts Storybook with no cache and runs the full scan.
|
||||
- **Do not count the first test.** In one CI run of this harness, the first test per worker took 5.7-7.0 s on Linux and 14-15 s on Windows. Warm tests had a median of 1.5 s (app) and 2.0 s (session-ui) on Linux and 2.4-2.6 s on Windows. A warm `mount` (navigation plus root wait) had a median of 1.0 s (app) and 1.5 s (session-ui) on Linux and 1.7-1.9 s on Windows, which is most of a typical test. Judge cost by warm attempts.
|
||||
- **Local reuse.** Playwright reuses whatever answers on the port (`config.ts:20`; default 6006, `config.ts:3`). That hides cold cost and reloads, and with several worktrees another checkout's Storybook can serve your tests its own code. Set `PLAYWRIGHT_STORYBOOK_PORT` or `PLAYWRIGHT_STORYBOOK_URL` (`config.ts:3-4`). On Windows pick a port below 49152: the dynamic range starts there, and Hyper-V and WSL reserve blocks inside it (`netsh int ipv4 show excludedportrange protocol=tcp`). Two Storybooks in one checkout share, and race on, `packages/storybook/node_modules/.cache/storybook/<version>/<hash>/sb-vite`, so a separate port is not enough: use a separate worktree.
|
||||
- **Keep mounts few.** Put one scenario in each test, and resize in place with `page.setViewportSize` (`composer.spec.ts:28-29`) instead of mounting again.
|
||||
- `a11y.manual` makes addon-a11y skip its axe run after each render, so changing `a11y.test` in `preview.tsx:133-135` is not enforced by these tests. Assert accessibility in the spec when it is the contract.
|
||||
- CI records video only on the first retry, next to the trace; local runs keep `retain-on-failure` (`config.ts:27-30`).
|
||||
|
||||
## Mobile, touch and drawers
|
||||
|
||||
- **Touch.** Use `story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })` inside a `story.describe` (`packages/session-ui/component-tests/session-timeline-mobile.spec.ts:3-4`). These options are test-scoped, so the shared workers stay. Prove that emulation took effect with `matchMedia("(hover: none)")` (`composer.spec.ts:244`), and interact with `tap()` or `page.touchscreen.tap` (`session-timeline-mobile.spec.ts:41-42`).
|
||||
- **No `launchOptions`.** They are worker-scoped, so the file gets its own worker hash and its own workers. App e2e had to move `packages/app/e2e/regression/mobile-timeline-scroll.spec.ts:18` into its own `fullyParallel` project (`packages/app/playwright.config.ts:69-86`) because it otherwise formed one long serial group running alone on a single worker.
|
||||
- **Drawers.** Below 768 px, pickers open in a bottom drawer (`composer.spec.ts:100-107`).
|
||||
- @corvu/drawer 0.2.4 lost a close requested in the first frames after opening: focus stayed trapped in an off-screen sheet and `body` kept `pointer-events: none`. Before the fix, this caused most first-attempt failures of `composer.spec.ts:5`, in 17 of 31 Linux runs.
|
||||
- `patches/@corvu%2Fdrawer@0.2.4.patch` (root `package.json` `patchedDependencies`) fixes it, so tests need no wait before pressing Escape or tapping outside.
|
||||
- `composer.spec.ts:375-429` guards the patch. It dismisses in-page, before and as `data-opening` appears, because Playwright input cannot target those frames. Upgrading @corvu/drawer drops the patch, so rerun those tests.
|
||||
- Before measuring or screenshotting a drawer, wait until it is fully in the viewport and has no `data-transitioning` (`packages/app/e2e/regression/mobile-model-pickers.spec.ts:25-26`).
|
||||
- A stuck overlay is a product bug. Fix the product and guard it in-page, as the patch did, rather than waiting it out.
|
||||
|
||||
## Geometry, platforms, known failures
|
||||
|
||||
- **Fonts.** Inter's `@font-face` has no `font-display` (`packages/app/src/index.css:13-18`), so a story can lay out with fallback metrics and then shift. Windows CI saw 6.5 px in the composer overflow measurement (`composer.spec.ts:93-95`), which now waits first (`:25-26`). Before measuring, run `await page.evaluate(() => document.fonts.ready.then(() => undefined))` (`markdown.spec.ts:247`). The `.then` keeps the `FontFaceSet` from being returned to Node.
|
||||
- **Page scroll.** Outside fullscreen, the preview frame is `calc(100vh - 2rem)` to fit Storybook's 1rem padding (`preview.tsx:82-84`). With `100vh`, every such story scrolled by 32 px, and any scroll-into-view shifted boxes (`composer.spec.ts:116` saw y go from 300 to 268). Do not give a story root `100vh`. A story taller than the viewport still scrolls, so measure relative to a container captured at the same moment, or use `parameters: { layout: "fullscreen" }`.
|
||||
- **Shortcuts.** Write them as `ControlOrMeta+…` (`browser-pane-restore.spec.ts:134`). Derive expected labels from `navigator.platform` (`packages/app/component-tests/session-question-dock.spec.ts:121`).
|
||||
- **Known local failure.** On macOS, `browser-pane-restore.spec.ts:123` fails after `ControlOrMeta+w` (Meta+W, `:134-135`), while CI (Control+W) passes. It is not your regression.
|
||||
- **`story.fail`.** It waits out the full 10 s expect timeout on every run: `packages/session-ui/component-tests/file-tool.spec.ts:49-50` took 12.6 s on Linux CI and 14.8 s on Windows CI. Fix the product instead, or make the expected failure an immediate check once the state has settled.
|
||||
|
||||
## Before you push
|
||||
|
||||
From the owning package (`packages/session-ui` or `packages/app`):
|
||||
|
||||
1. **Repeat** at CI's worker count. Locally retries are already 0 (`config.ts:14`). Every attempt must pass: `bun run test:components -- component-tests/<file>.spec.ts --workers=2 --repeat-each=10`.
|
||||
2. **Read durations** in `bunx playwright show-report component-tests/playwright-report`. Compare warm tests with the CI medians above. Anything much slower needs a reason in the work the test does.
|
||||
3. **Rehearse load.** Late font swaps and focus restored after a menu closes (`composer.spec.ts:114-115`) race the test under load. In a throwaway copy of the spec, throttle before `mount` with `const cdp = await page.context().newCDPSession(page)` and then `await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 })`. Alternatively, repeat while the other suite runs.
|
||||
4. **Run each suite cold** whenever you add a story, fixture, dependency, worker or lazy import, or change `packages/storybook`. From the repo root, once per suite, as CI runs each in its own leg. Playwright pipes Storybook's output into its own (`config.ts:22-23`), and a free port makes it start a fresh server:
|
||||
|
||||
```sh
|
||||
rm -rf packages/storybook/node_modules/.cache
|
||||
PLAYWRIGHT_STORYBOOK_PORT=6216 bun run --cwd packages/app test:components -- --workers=2 2>&1 | tee /tmp/app-components.log
|
||||
grep -E "new dependencies optimized|optimized dependencies changed" /tmp/app-components.log
|
||||
```
|
||||
|
||||
The `grep` must print nothing. Count optimizer events, not durations: under load, the same config ran anywhere from 127 to 189 s.
|
||||
|
||||
5. **Windows.** Check platform keys and labels and font-dependent geometry. The Windows component legs are the authoritative check.
|
||||
- Do not skip on Windows, and do not raise `config.ts` timeouts, retries or the `mount` wait.
|
||||
- If one test does genuinely expensive work, call `story.setTimeout(...)` in that test, with a comment that names the work.
|
||||
6. **Confirm CI ran the component legs.** E2E never runs on pushes to dev (`test.yml:28`), so no post-merge run catches a regression.
|
||||
- The `affected` gate filters on `@opencode/app` and `@opencode/storybook` (`test.yml:52-53`). `ui`, `session-ui` and `gui-extensions` count through app's dependencies. `workflow_dispatch` forces the legs on (`test.yml:48-51`).
|
||||
- `test.yml` is listed in `globalDependencies` (`turbo.json:8`), so editing it marks every package affected.
|
||||
- The required `e2e (linux)` and `e2e (windows)` checks also pass when no leg was needed (`test.yml:316-335`). Look for `e2e (<os>, session-ui-components)` and `e2e (<os>, app-components)`.
|
||||
- If CI skipped them, report your local cold run in the PR.
|
||||
|
||||
## When CI flakes
|
||||
|
||||
- **Download the artifact.** Run `gh run download <run-id> -n playwright-<linux|windows>-<session-ui|app>-components-<attempt> -D <dir>` (`test.yml:301-314`). Results are in `<dir>/session-ui/component-tests/` or `<dir>/app/component-tests/`. Only the first retry has a trace and, in CI, a video: `test-results/<test>-components-retry1/`.
|
||||
- **Find reloads.** Storybook's output reaches the job log with a `[WebServer]` prefix, so grep the log for `optimized dependencies changed`. In the retry trace, a reload shows up as any of:
|
||||
- a second `iframe.html` load and a second `[vite] connecting...`;
|
||||
- the deps `?v=` hash changing between requests;
|
||||
- 504 "Outdated Optimize Dep" on `deps/*.js`.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: tui-tests
|
||||
description: Use when writing, fixing, or reviewing tests that render OpenTUI (testRender, createTestRenderer, createAppFixture) in packages/tui or packages/cli. Covers frame waits (waitForFrame, waitFor, waitForVisualIdle, flush), started vs non-started renderers, injected clocks, mock keyboard and mouse input, terminal sizes, cleanup and shared paths, and the 30s budget. Also use when a TUI test fails with "Timed out waiting for frame predicate after N passes", or when a tui test file is slow. For cli subprocess and service tests, use the unit-tests skill.
|
||||
---
|
||||
|
||||
# TUI tests
|
||||
|
||||
packages/tui runs about 170 test files in one `bun test` process. CI runs it next to up to two other suites on 4-vCPU runners (`bun turbo test --affected --concurrency=3` in `.github/workflows/test.yml`), where it has taken 136-146s on Linux and 147-171s on Windows, most of it idle on frame pacing, production timers and sleeps. Most flakes come from waiting on the wrong signal and then running out of frames on a busy runner.
|
||||
|
||||
Read the root `AGENTS.md` Testing section (no `globalThis` mocks, run from the package directory) and its TUI Stories section. To drive the real TUI, use the `opencode-dev` skill. For CLI subprocesses, service deadlines, Effect `TestClock` and turbo test tasks, use the `unit-tests` skill.
|
||||
|
||||
## Frame budgets are not time
|
||||
|
||||
- `waitForFrame`, `waitFor` and `flush` give up after `maxPasses` (default 20). `waitForVisualIdle` gives up after `maxFrames` (default 20). One pass is one rendered frame (`DEFAULT_MAX_PASSES` in `@opentui/core/testing`). "Timed out waiting for frame predicate after 20 passes" comes from this budget, not from bun's timeout.
|
||||
- **Started renderer** (`setup.renderer.start()`, as `createAppFixture` does at `packages/tui/test/fixture/app.ts:28`): frames tick at OpenTUI's default 30 fps, so 20 passes are about 0.66s. Production renders at `targetFps: 60` (`packages/tui/src/app.tsx:241`), so test frames are twice as slow as the real app.
|
||||
- **Not started** (the default for `testRender` and `createTestRenderer`): `waitFor` and `waitForFrame` fail within milliseconds when no render is scheduled, and the error shows `isRunning: false`. In the same state `flush()` and `waitForVisualIdle()` return immediately without error; they do not wait for fetches or timers. Await the signal, then `renderOnce()`.
|
||||
- Wait on frames only for something a frame produces. For anything else, wait on the signal itself:
|
||||
- **Focus set on a timer.** `DialogSelect` focuses its filter with `setTimeout(..., 1)` (`packages/tui/src/ui/dialog-select.tsx:702`), which takes about 15ms on Windows. `typeText` sends keys to whatever is focused, so early keys are dropped and Enter then picks the default option. Put the focus in the predicate, as both dialog waits in `packages/tui/test/command-selection.test.tsx` do (`:68-70` for the model, `:75-80` for the variant): `setup.renderer.currentFocusedRenderable instanceof InputRenderable`. Before the variant wait had it, the test flaked in 5 CI runs in 20 hours, including a dev push, and a bigger budget did not help: it still failed with `maxPasses: 150`, because the keys were lost, not late. If a wait fails with the focus in the predicate, look for a state race, not the budget.
|
||||
- **Markdown and code text.** `<markdown>` (`packages/tui/src/routes/session/message-parts.tsx:182`) draws no text until the shared tree-sitter worker replies, while frames keep ticking, so a plain `waitForFrame` can spend all 20 passes on a loaded runner with the text still pending. Use `waitForHighlightedFrame(setup, predicate, options?)` from `packages/tui/test/fixture/frame.ts`. When the predicate fails, it awaits every in-flight `CodeRenderable.highlightingDone` inside the frame predicate, and awaiting there does not use up passes. It accepts any setup with `renderer` and `waitForFrame`; app-lifecycle, session-group-navigation and session-home wait on transcript text this way. If the code under test accepts a client, inject `new MockTreeSitterClient({ autoResolveTimeout: 0 })` instead (`packages/tui/test/mini/scrollback.surface.test.ts:87`).
|
||||
- **Requests and other state.** Await the deferred response your fetch handler returns. With no signal to await, poll with a wall-clock deadline instead of raising `maxPasses`: the file-local `waitForFrame(setup, matches)` at `command-selection.test.tsx:117` checks `captureCharFrame()` every 10ms for up to 2s. `wait` in `packages/tui/test/cli/tui/data.test.tsx:37` and `waitForModel` in `packages/tui/test/fixture/local.tsx:109` do the same for state.
|
||||
- `waitForVisualIdle` returns on the first frame with `cellsUpdated === 0` by default. A frame waiting on a highlight is also quiet, so wait for the text before you measure layout or assert on it: `session-home.test.tsx:227` waits for "History message 0001" with `waitForHighlightedFrame` after `waitForVisualIdle()` instead of reading the frame directly.
|
||||
- Settle steps cost frames. Each `waitForVisualIdle({ quietFrames: 3 })` takes at least 3 frames, about 100ms at 30 fps. `session-group-navigation.test.tsx` has 14 of them. When you know what you are waiting for, wait for that frame instead.
|
||||
- Do not use `maxPasses` as a clock. Several waits do, and each assumes 33ms frames:
|
||||
- `app-lifecycle.test.tsx:792`: 120 passes to cover the 1s reconnect delay after `setup.events.disconnect()` at `:784`;
|
||||
- `app-lifecycle.test.tsx:1721-1723`: 200 passes per retry-countdown second;
|
||||
- `component/dialog-shell-output.test.tsx:144`: 100 passes for a 1s poll, plus `Bun.sleep(1100)` at `:163` and `:182`;
|
||||
- `session-panel.test.tsx:590`: 120 passes in `ready()` for the initial load.
|
||||
|
||||
With test frame rates scaled 4x, dialog-shell-output (3 tests) and session-activity-anchor (2 tests) failed. The same budgets are why tests cannot yet run at production fps. A larger budget is fine only for a long run of frame-driven work, such as scrolling 50 rows.
|
||||
|
||||
## Pick the renderer mode and clock
|
||||
|
||||
- **Component logic:** `testRender(() => <X />, { width, height })` without `start()`. Change state, `await app.renderOnce()`, then assert on `captureCharFrame()`. One frame, deterministic.
|
||||
- **Time-driven renderables:** pass `clock: new ManualClock()`, call `renderer.pause()`, then advance the clock and `renderOnce()` (`fixture()` in `packages/tui/test/component/retry-provider.test.tsx:8`). `pause()` also stops `requestLive()` (spinners, animations) from starting the frame loop.
|
||||
- **Clock order:** inject a clock first (OpenTUI `ManualClock`; Effect `TestClock` for Effect code, see `unit-tests`). Use `jest.useFakeTimers()` only when nothing is injectable: it patches the global timers of a process that runs every tui file, so restore them with `jest.useRealTimers()` in `finally` (`packages/tui/test/util/delayed-presence.test.ts:24-26`).
|
||||
- **Whole app:** `createAppFixture` (`packages/tui/test/fixture/app.ts`) gives a started renderer, an in-process `Bun.serve` and `kittyKeyboard: true`. Call `await setup.ready` before the first wait. Passing `config` replaces the default `{ animations: false }`, so include `animations: false` yourself. The first use in a process pays `await import("../../src/app")`: about 1s idle, 12-25s under heavy load. Use it only when the behavior spans routes or data.
|
||||
- **Server behavior:** use the existing `createFetch` and `createEventStream` fixture (`packages/tui/test/fixture/tui-client.ts:15`, `:69`) rather than spawning a server. The persistent PTY, for example, is faked as `/api/experimental/persistent-pty/...` routes plus a `Bun.serve` websocket (`packages/tui/test/session-wheel-focus.test.tsx:52`); real PTY behavior is tested in `packages/server/test/persistent-pty.test.ts`. Every fixture server listens on `port: 0` (`fixture/app.ts:32`) and is reached through `server.url`; never hard-code a port. Windows hands out ephemeral ports from 49152 up, so a fixed port in that range can already be taken by another connection.
|
||||
|
||||
## Keyboard and mouse
|
||||
|
||||
- Use `setup.mockInput`: `pressKey`, `typeText`, `pressEnter`, `pressEscape`, `pressArrow`, `pasteBracketedText`.
|
||||
- Create renderers with `kittyKeyboard: true`. Without it, `mockInput` silently sends something else (OpenTUI 0.5.17):
|
||||
|
||||
| Input | Without kitty mode | With kitty mode |
|
||||
| -------------------------------------------- | ----------------------------------------- | --------------- |
|
||||
| `pressKey("u", { ctrl: true, shift: true })` | `ctrl+u` | `ctrl+shift+u` |
|
||||
| `pressKey("2", { ctrl: true })` | `2` | `ctrl+2` |
|
||||
| `pressEnter({ shift: true })` | `return` | `shift+return` |
|
||||
| `pressEscape()` then `pressKey("a")` | `meta+a` (a lone Escape is held for 20ms) | `escape`, `a` |
|
||||
|
||||
- A second click on the same renderable within 500ms (renderer clock) and within 1 cell becomes a word or line selection. Do not sleep past that window: `Bun.sleep(550)` at `packages/tui/test/session-activity-anchor.test.tsx:120` costs 4.4s per file. Call `setup.renderer.clearSelection()` first, which resets the click history, or click at least 2 cells away. In a `ManualClock` renderer driven by `renderOnce()`, `clock.advance(501)` also works.
|
||||
|
||||
## Terminal sizes
|
||||
|
||||
- Always pass `width` and `height`. Otherwise the renderer uses `process.stdout.columns`: your terminal locally, 80 in CI.
|
||||
- Cover layout at a narrow and a wide width, for example `test.each([100, 44])` (`packages/tui/test/app-lifecycle.test.tsx:13`). After `setup.resize(w, h)`, `await setup.renderOnce()` or `flush()` before asserting. Run any real-time sequence at one width only.
|
||||
- Measure work by counting, not timing: mounted rows (`packages/tui/test/session-mount-budget.test.tsx`), rendered diff lines (`packages/tui/test/cli/tui/diff-viewer.test.tsx:2198`). `performance.now()` thresholds fail on slow Windows runners.
|
||||
|
||||
## Timers, cleanup and shared state
|
||||
|
||||
- A TUI test that takes over a second is usually waiting on a production constant; make it injectable instead of waiting it out. Two such constants: the shell output poll `setTimeout(poll, more ? 0 : 1_000)` (`packages/tui/src/component/dialog-shell-output.tsx:73`; 6 tests, 18.8s, 95% idle) and `reconnectDelay = 1_000` (`packages/client/src/solid/connection.ts:33`; about 1s per reconnect test). connection.ts already takes `idleTimeout` and `flushInterval` options, so add a sibling.
|
||||
- Clean up with `await using` or `finally`: destroy the renderer and stop the fixture server. Cancel timers first, or they throw "TextBuffer is destroyed" into the next test.
|
||||
- Remove the tmpdir last. Destroy the renderer and await the app task first, as `createAppFixture`'s `[Symbol.asyncDispose]` does (`fixture/app.ts:63`). Removing it under a pending write logs "Failed to persist" and flock "lock is compromised".
|
||||
- `TestTuiContexts` (`packages/tui/test/fixture/tui-environment.tsx:32-35`) defaults `home` and `state` to fixed `/tmp/opencode/...` paths. When the tree mounts a provider that writes under `paths.state` (`StorageProvider` in `src/context/storage.tsx:145`, `LocalProvider`, or the prompt history, frecency and stash providers), pass `paths={{ state, home }}` built from `tmpdir()` (`test/fixture/fixture.ts:8`). Process-shared paths are what keep tui from parallel workers: `--parallel=2 --no-isolate` halved local wall time (162.9s to 80.7s). Isolated mode re-imports `src/app` in each of the 18 test files that load it, at 6x the CPU.
|
||||
- Bun does not cancel a timed-out test body. It keeps running and can fail the next test or rerun as "Unhandled error between tests". Triage the first failure in a file; the ones after it may be leftovers.
|
||||
- Import another package's test helpers by relative path only from a package in tui's workspace dependency closure. `@opencode/tui#test` depends on `transit` in `turbo.json`, which folds tui's whole workspace dependency closure into the test hash, so `data.test.tsx:7` reaching `../../../../core/test` is covered because tui depends on `@opencode/core`. A package outside that closure is not hashed, and a change there can replay a stale pass (see `unit-tests`, Turbo test tasks).
|
||||
|
||||
## Timeouts and failures
|
||||
|
||||
- The tui and cli test scripts run `bun test --timeout 30000`; Bun's default is 5s. In tui, that budget exists for the cold `src/app` import, not for slow waits.
|
||||
- A frame-budget failure is not a timeout problem. Raising the test timeout does nothing for it, and raising `maxPasses` only hides the wrong wait.
|
||||
- A per-test timeout below 30s, like the 15000 at `session-group-navigation.test.tsx:249`, must still cover the cold import when the file runs alone. New per-test timeouts name their work in a comment (see `unit-tests`, Know the budget).
|
||||
- A frame-budget failure that reproduces on every run of one branch is a regression in that change, not a flake. dialog-shell-output failed "after 100 passes" in 21 of 29 tui runs on one PR stack and in 0 of about 102 elsewhere. Do not answer it with a bigger budget.
|
||||
- Do not add `skipIf(process.platform === "win32")` to hide a timing flake; fix the wait. Windows timers have about 15ms resolution, so a 1ms `setTimeout` race that never shows on macOS fails there.
|
||||
|
||||
## Before you push
|
||||
|
||||
- From `packages/tui`: `bun test --timeout 30000 test/<file>`. This matches CI's budget and prints per-test milliseconds, which the package script's `--only-failures` hides.
|
||||
- `bun test --timeout 30000 test/<file> --rerun-each 10`: all 10 must pass. The first iteration pays the cold import, so a timeout on iteration 1 under heavy load is that import, not your wait. Repeat while one other suite runs (for example `bun run test` in `packages/core`), not on an overloaded machine. Before its focus wait, the command-selection race failed 5 of 8 local runs under load.
|
||||
- Force the race you suspect: temporarily slow the timer or reply (for example the focus `setTimeout` to 150ms). The test must still pass. Revert afterwards.
|
||||
- Negative control: make the predicate impossible. The test must fail fast with a frame dump, not hang until the timeout.
|
||||
- Windows-sensitive behavior to check for: timers under 16ms, renaming a file another process holds, process kill, and paths shown in frames. On pushes and pull requests the unit job runs `--affected`, so `@opencode/tui:test` is scheduled only when tui, a workspace package it depends on, or a `globalDependencies` file in `turbo.json` changed. In the `unit (windows)` log, confirm the lines prefixed `@opencode/tui:test:` are there with `cache miss, executing` and a pass, and compare its `Ran N tests across M files. [Xs]` line with a recent run that included tui. Per-file timing is only available locally.
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
name: unit-tests
|
||||
description: Use when writing, reviewing, speeding up, or fixing a flaky `bun test` unit test in any package (core, cli, server, ai, codemode, util, client, sdk, plugin, http-recorder, merman, app src and test-browser, desktop, gui-extensions, ui, session-ui, latex, ...), except OpenTUI renderer tests (see tui-tests). Also use when a test "timed out after 5000ms" on Windows CI, fails only on Windows, when the unit job is slow, when adding cli subprocess or service tests, or when adding a package's test task to turbo.json. Covers controlling time instead of sleeping, per-test timeouts, process and git spawn cost on Windows, Windows-only failures, temp dirs and process state, leaks between files, and reading CI timeouts.
|
||||
---
|
||||
|
||||
# Fast, Reliable Unit Tests
|
||||
|
||||
CI runs `bun turbo test --affected --concurrency=3` on 4-vCPU Linux and Windows runners (unit job in `.github/workflows/test.yml`), so each suite shares the CPU with two others, and Windows is the slower leg. Most slow or flaky unit tests here wait on real time, spawn too many processes, or assert wall-clock speed.
|
||||
|
||||
Read these first; this skill does not repeat them:
|
||||
|
||||
- Root `AGENTS.md`: the Testing and Style Guide sections.
|
||||
- `packages/app/AGENTS.md` Tests (`packages/desktop` and `packages/gui-extensions` follow it): one owner per contract, and no production seams for tests.
|
||||
- `packages/ai/AGENTS.md`: cassettes. The `effect` skill: `it.effect` and `it.live`.
|
||||
|
||||
Sibling skills: `tui-tests` (OpenTUI renderer tests in tui and cli), `app-e2e-tests` (Playwright in `packages/app/e2e`), `component-tests` (Storybook Playwright). This skill owns cli subprocess and service tests.
|
||||
|
||||
## Know the budget
|
||||
|
||||
- Bun's default per-test timeout is 5000ms. Check the package's `test` script:
|
||||
- 30s: ai, cli, desktop, http-recorder, latex, merman, simulation, theme and tui pass `--timeout 30000`; core's `script/test.ts` passes `--timeout=30000`.
|
||||
- 5s: client, httpapi-codegen and sdk pass `--timeout 5000`; app, codemode, function, gui-extensions, plugin, plugin-browser, script, server, session-ui, ui and util use the default.
|
||||
- Size Windows risk by counting spawns, not by local wall time.
|
||||
- npm.test.ts "installs a Git package from an npm ::path: subdirectory at a branch" takes 0.4s locally and hit 5000ms on Windows CI. Each Npm.add Git case makes about 75 git process events: 3 `clone --recurse-submodules`, which run the git-submodule shell script.
|
||||
- Assume roughly 40ms per Git-for-Windows spawn (estimated from an earlier git.test run on Windows CI: ~24s for 564 spawns). Some core file groups run 5-9x slower on Windows than locally.
|
||||
- Those `test.each` cases (`npm.test.ts:223`, `:251`) hit 5000ms in 13 of 45 Windows core runs; that is why core's runner now defaults to 30s (comment at `packages/core/script/test.ts:38`).
|
||||
- A timeout loosens the budget only when it exceeds the package default.
|
||||
- In a 5s package, work that legitimately needs longer gets a per-test timeout and a comment naming the work: `packages/server/test/fetch.test.ts:406-407` ("Real retries wait 18 \* 200 ms ...", `{ timeout: 10_000 }`). `test.each(table)(name, fn, 30_000)` takes it as the third argument. Use file-wide `setDefaultTimeout` only when every test in the file does such work.
|
||||
- In core, the four file-level `setDefaultTimeout(30_000)` calls only restate the runner default, and a per-test `{ timeout: 15_000 }` (still in `tool-shell.test.ts`, `session-generate.test.ts` and others) is stricter than it. Do not add values at or below 30s there.
|
||||
- Never absorb a sleep, poll or production interval with a larger timeout, `--retry`, or `skipIf(win32)`; remove the wait. Skip on Windows only when the platform lacks the capability, with a comment (`npm.test.ts:313`, symlinks).
|
||||
|
||||
## Control time; do not wait for it
|
||||
|
||||
- **Effect code.** `it.effect` from `packages/core/test/lib/effect.ts` runs on `TestClock`; `testEffect(layer)` adds your layer. server and cli import it from `../../core/test/lib/effect`; ai has `packages/ai/test/lib/effect.ts`.
|
||||
- **Fork, then adjust.** Fork with `Effect.forkChild({ startImmediately: true })` (`packages/ai/test/http-timeout.test.ts:58`), or await a Deferred the code completes and then `Effect.yieldNow` (`:80-81`). Otherwise `TestClock.adjust` can run before the sleep is registered, and the join hangs until Bun's timeout.
|
||||
- **Debounced streams and watchers.** `advance(condition)` and `drain` in `packages/core/test/lib/clock.ts` alternate one real 1ms macrotask with 500ms adjusts, because debounces register sleeps across fiber hops. Use `drain` (4 such steps) before a "nothing happened" assertion instead of `Bun.sleep(25)`. `advance`'s 100-step bound is right under TestClock, but a condition waiting on slow real work (a child, a large write) can run out of steps under load.
|
||||
- **Real I/O between virtual steps.** `TestClock.withLive(Effect.sleep("10 millis"))`. `registered()` at `packages/cli/test/service.test.ts:740` tests the 5s registration watch with real file I/O under `it.effect`. This departs from the effect skill's "filesystem → `it.live`" on purpose: the timer is the contract.
|
||||
- **Real events need `it.live`.** Under `it.effect` the clock moves only when the test adjusts it, so an Effect timeout on a path driven by real processes, sockets or file events never fires and a missing event hangs instead of failing. The AppProcess tests (`packages/core/test/process/process.test.ts:13-14`) use `it.live` because the spawner's 1-second post-exit capture deadline (`packages/util/src/cross-spawn-spawner.ts:322`) never fired under TestClock.
|
||||
- **Give a racing component its own clock.** When a real-time deadline races slow setup, provide that component a `Clock` that only the test moves past the deadline. `packages/core/test/browser-idle.test.ts:24-38` wraps the live clock for `LocationActivity` (`:49-51`) and adds the TTL at `:84`, once the browser state is attached. Before, it waited out a real 2s TTL and failed on Windows CI with "RPC is unavailable: experimental.browser" when plugin activation was slow. Shortening a real TTL makes that race flakier.
|
||||
- **Product Effect code must stay on the caller's clock**, which is what makes both patterns above possible. Read time through `Clock`, wait with `Effect.sleep` or `Schedule`, and run nested effects with the caller's context.
|
||||
- A bare `Effect.runPromise` runs on the default real clock. McpStdio does this (`packages/core/src/mcp/stdio.ts:66`, `:82`, `:109`), so `packages/core/test/mcp.test.ts:832` waits the real 2s `CLOSE_GRACE` on every OS.
|
||||
- Capture `yield* Effect.context()` and use `Effect.runPromiseWith(context)`, as `packages/cli/src/acp/agent.ts:27` does.
|
||||
- **Code outside Effect** (app, desktop, ui, session-ui): `jest.useFakeTimers()` and `setSystemTime` from `bun:test`, restored in `afterEach` (`packages/desktop/src/main/extension/lifecycle.test.ts:27-32`, `packages/desktop/src/main/storage/drafts.test.ts:15-17`).
|
||||
- **Advance fake timers to the next timer** (`jest.advanceTimersToNextTimer()`, `jest.runOnlyPendingTimers()`), not in small fixed steps. The 10ms-step `advance` at `lifecycle.test.ts:37` turns the 1,000-seed fuzz test (`:607`) into about 430k event-loop turns: 2.8-3.8s on Windows, 0.56s on Linux.
|
||||
|
||||
## Real-time waits: deadlines, not attempt counts
|
||||
|
||||
- Bound a real-time poll in milliseconds, generously, and return as soon as it holds: `until` at `packages/cli/test/service-lifecycle.test.ts:267` (30s), `eventually` at `packages/core/test/integration.test.ts:37` (10s).
|
||||
- Attempt-count loops hide a small budget that shrinks under load. Do not copy `waitUntil` at `packages/core/test/config/reload.test.ts:371` (100 × 10ms; the skills test at `:57` timed out after 1.2s on a Linux dev push) or `packages/core/test/plugin/provider-azure.test.ts:219` (3000 × 1ms).
|
||||
- Poll for every state you assert afterwards: the reload poll at `reload.test.ts:301-317` includes the skills that `:319-320` assert.
|
||||
- Let the test, not a timer, release a child's next step. The login command in `integration.test.ts:230-254` prints its secret only after the test, having seen the pending status, writes a release file; it used to print after a fixed `Bun.sleep(50)`.
|
||||
- Small timer gaps race too: `packages/core/test/process/process.test.ts:59-67` ordered stdout and stderr with 10ms and 20ms child timers and failed on Windows CI (`out 2` read before `err 1`); it now uses 100ms and 200ms.
|
||||
|
||||
## When the wait happens in another process
|
||||
|
||||
TestClock cannot reach a spawned server or worker. These production waits already cost real time:
|
||||
|
||||
- cli (about 60s of its roughly 2-minute suite): the 15s bind-retry deadline (`Date.now() + 15_000` in `packages/cli/src/server-process.ts`), the 5s registration watch (`packages/cli/src/services/service-registration.ts:63`), and fixed sleeps (`Bun.sleep(8_000)` at `service.test.ts:620`, `Bun.sleep(5_500)` at `service-lifecycle.test.ts:237`).
|
||||
- core (about 8s locally): McpStdio `CLOSE_GRACE` 2s + `FORCE_KILL_AFTER` 2s (`stdio.ts:10-13`), Shell `forceKillAfter` 3s (`packages/core/src/shell.ts:306`, `:401`), and the 1s capture deadline in cross-spawn-spawner.
|
||||
|
||||
To avoid adding more:
|
||||
|
||||
- Test the timing logic in-process under TestClock (the `registered()` tests at `service.test.ts:270-299`). Keep real processes for exit codes, signals, stdio and port binding, with one test per escalation path. Do not add tests that wait out these constants.
|
||||
- Shorten a production duration through an option, not by waiting it out. Precedent: real product options (`Flock.Options` in `packages/util/src/flock.ts:41-47`, `idleTimeout`/`flushInterval` in `packages/client/src/solid/connection.ts`) and client's hidden seam `withEnsureTiming` (`packages/client/src/service-timing.ts:31`, used by `packages/client/test/fixture/service-timing.ts`).
|
||||
- For a spawned process the value must reach the child, as `timeoutMs` does through the worker payload at `packages/core/test/util/flock.test.ts:50-58`.
|
||||
- server-process.ts has no override today, so its 15s tests cannot be shortened from the test side.
|
||||
- app, desktop and gui-extensions forbid such seams (their AGENTS.md Tests sections); core, cli, util, client and tui have no such ban.
|
||||
- Retry timing belongs in core under TestClock. `packages/server/test/fetch.test.ts:363-408` spends about 5.8s of server's 16s in real 200ms bind retries (`packages/core/src/plugin/provider/openai.ts:21`). The fixed ports 1455/1457 are the contract there; new server tests cover routing and error mapping only. Elsewhere bind port 0 (`packages/app/src/runtime/server/global-sync/bootstrap.test.ts:81`).
|
||||
|
||||
## Do not assert wall-clock speed
|
||||
|
||||
- `expect(performance.now() - startedAt).toBeLessThan(500)` failed on Windows at 510ms (`packages/ui/src/context/marked-parser.test.ts:84`); `packages/core/test/shell-scan/adversarial.test.ts:77` has the same shape. merman's layout audit no longer asserts its elapsed time.
|
||||
- Count operations, or pick an input where a regression is catastrophic and assert only the result, so it trips the test timeout: `packages/codemode/test/openapi.test.ts:638` (depth 30 is 2^30 work without memoization). A ratio of two timings still fails on a load spike; if you keep one, use a generous factor and say it is timing-sensitive.
|
||||
|
||||
## Spawn less
|
||||
|
||||
- **Process start-up.** Each `bun run src/index.ts` CLI boot costs about 1.3s of CPU (the entry loads TUI `.tsx` through the preload in `packages/cli/bunfig.toml`). Test parsing and validation in-process; keep one or two real subprocess tests per file. Each cold bun child that imports the MCP SDK cost 1-3s; the MCP lifecycle tests, which spawned several, hit 5000ms on Windows until they moved to the in-process `resourceServer()` (`packages/core/test/mcp.test.ts:90`).
|
||||
- **Spawned CLIs** use `isolatedEnv(root)` from `packages/cli/test/fixture/environment.ts`: isolated HOME, XDG and database, shared transpiler cache. `packages/cli/test/upgrade.test.ts:98` and `debug-paths.test.ts:24` set a private `XDG_CACHE_HOME` without `BUN_RUNTIME_TRANSPILER_CACHE_PATH`, so every spawn transpiles again; do not copy them.
|
||||
- **Git.** Before the shared fixture below, a PATH shim counted about 2,850 git spawns per core run, and git.test took about 24s on Windows CI and 7s locally.
|
||||
- Create repos with `packages/core/test/fixture/git.ts`. `initGit(directory, ...initArgs)` (`:24-27`) runs one `git init` and appends the fsmonitor, gpgsign and identity settings to `.git/config` in one write; `initRepo` (`:64-67`) adds an empty root commit (2 spawns; with one `git config` per key it took 6), and `gitRemote` builds on `initGit`.
|
||||
- Do not write per-file `git init` plus `git config` copies. server still has two (`packages/server/test/vcs.test.ts:215-218`, `packages/server/test/worktree.test.ts:131-133`); server already imports core fixtures such as `tmpdir`.
|
||||
- Use `-c user.name=… -c user.email=…` only for commits the test makes itself (`npm.test.ts:53`). Repos where production code commits later (snapshot, worktree, vcs) need repo-local identity.
|
||||
- Keep identity repo-local. The unit job's "Configure git identity" step sets a global identity, but core's `script/test.ts` and cli's `isolatedEnv` replace HOME, so it is invisible there and visible to server and tui. That HOME isolation keeps core's missing-identity coverage: do not depend on CI's identity, and do not add a global gitconfig in core.
|
||||
- **Independent children** start together (`Promise.all`, `packages/ai/test/exports.test.ts:63`). `packages/app/test-browser/playwright-config.test.ts` runs sequential `Bun.spawnSync` (3.5s on Windows, 1.4s on Linux); `packages/desktop/scripts/windows-installer.test.ts` makes 6 sequential cold `pwsh` starts for ACL reads, which one `pwsh` call could batch.
|
||||
- **Size contention and thresholds to the claim.** `flock.test.ts:46` starts 16 bun workers; use only as many as still exceed the 4 CI vCPUs. A hard-coded threshold forces huge fixtures: `packages/core/test/git.test.ts:354` writes 2,100 files to cross `looseObjectLimit = 2048` (`packages/core/src/git.ts:50`), 2.7s locally and slower on Windows. Make the threshold an option with the production default, cross a small value in the test, and assert the default once.
|
||||
|
||||
## Windows-only failures
|
||||
|
||||
- Renaming onto a file another process holds open fails. cli `service.test.ts:462` failed on a dev push with `FileSystem.rename (...service-local.json.<uuid>.tmp)` from the temp-then-rename at `service-registration.ts:39`. Close handles the test holds; when two product processes race, fix the product, not the test.
|
||||
- SIGTERM cannot be caught: `process.kill` ends the target at once with exit code 1, so a shutdown handler runs only on POSIX. Assert per platform with `catchesSigterm`, `stopSignal` and `stoppedExitCode` (`packages/client/test/fixture/service-fixture.ts:7-11`).
|
||||
- Do not prove a stopped server released its port by binding it again. Windows hands out ephemeral ports from 49152 up, and any process, including suites running alongside, may take the port for an outgoing connection; a re-bind failed there with "Failed to start server. Is port 65518 in use?". Assert that nothing accepts connections (`expectNotListening`, `service-fixture.ts:108-117`).
|
||||
- `os.tmpdir()` reads `TEMP` on Windows and `TMPDIR` on POSIX; set both for a child (`packages/util/test/global.test.ts:22-24`). Compare canonical paths with `fs.realpathSync.native`, which expands 8.3 short names such as `RUNNER~1` (`:84-86`).
|
||||
- Reset a test socket once the request has arrived (`data`), not on `open`. Resetting on accept races the client's write, and on Windows the lost race is a write or connect error instead of a reset: `packages/util/src/retry.test.ts:25-30` recorded 2 of its 3 expected failures there. `packages/app/src/runtime/server/global-sync/bootstrap.test.ts:83-88` still resets on `open` and failed 3 of about 19 loaded local runs with `write EPIPE`, which `transientMessages` in `packages/util/src/retry.ts` does not list.
|
||||
|
||||
## Temp dirs, files, process state
|
||||
|
||||
- core's `script/test.ts` points HOME, XDG\_\* and TMPDIR (plus USERPROFILE, TMP and TEMP on Windows) at a fresh directory per run. Per-test dirs: `tmpdir()` (`await using`), `tmpdirScoped` or `withTempDir` from `packages/core/test/fixture/tmpdir.ts`.
|
||||
- Close watchers, databases and children before the dir is removed. On Windows an open handle fails removal with `EBUSY`; `remove` in `tmpdir.ts` then forces a GC and retries every 100ms, up to 30 times.
|
||||
- Restore process state: env with `withEnv` (`packages/core/test/fixture/env.ts`) in Effect tests and in `afterEach` elsewhere, real timers, and `setSystemTime()`.
|
||||
- `CI=true` in the unit job, and Turbo passes it through. Code that reads `CI` takes another path there, so a test of the non-CI path unsets it itself: `packages/http-recorder/test/websocket.test.ts:132` wraps its record-mode case in `withEnvironment("CI", undefined, ...)`, because in CI that case replayed and waited forever for "open".
|
||||
|
||||
## Keep files independent without `--isolate`
|
||||
|
||||
- Except app, each suite runs all its files in one process with one module registry (core: 262 files), so state leaks between files.
|
||||
- Do not use `mock.module`; it leaks across files.
|
||||
- It is why app's scripts pass `--isolate`: app unit went from 2.4s to 9.9s locally, and unit plus browser from 5.3s to 12-20s on CI.
|
||||
- `packages/cli/test/mini-config.test.ts:25` and `packages/core/test/plugin/provider-gitlab.test.ts:74` already leak in non-isolated suites; do not copy them.
|
||||
- In Effect packages, provide the dependency through a test layer. In app, desktop and gui-extensions, drive the real module and entry point, and inject only through a parameter the product already has.
|
||||
- Never add `--isolate` or bare `--parallel` (which implies `--isolate`) to a test script: ai went from 7.6s to 26.3s with `--parallel=2`.
|
||||
- `--parallel=N --no-isolate` keeps the shared registry and measured 0.47-0.68x serial wall time at about the same CPU in core. It also exposed load-sensitive tests (browser-idle and integration's `eventually`, both since fixed).
|
||||
- Adopting it is a runner decision for maintainers once CI passes on both OSes, not a fix for one slow test.
|
||||
- Runtime imports run in every file that loads the module. `packages/desktop/src/main/storage/store.ts:3` imports `electron` only for `getStore`, so `src/main/extension/storage.test.ts` and `src/main/storage/store.test.ts` download the ~100MB Electron binary on a fresh install. On a first local run, storage.test.ts took 5.3s of file time for 0.1s of test bodies. Use `import type` for types (`packages/desktop/src/main/ipc-transport.test.ts:4`).
|
||||
|
||||
## Turbo test tasks
|
||||
|
||||
- `bun turbo test` runs every workspace package that has a `test` script, through the generic `test` task in `turbo.json` (`"dependsOn": ["transit"], "outputs": []`). A package with test files but no `test` script never runs in CI: give a new suite a script, and check with `bun turbo test --dry=json`. Add an `@opencode/<pkg>#test` entry only to override the generic task (desktop disables caching). Suites CI skipped rotted: client, util, sdk, http-recorder and merman needed fixes before CI could run them.
|
||||
- `transit` runs nothing and depends on `^transit`, so a test hash covers the package's whole workspace closure. The global dependencies add root `package.json` (whose `packageManager` pins Bun), `bunfig.toml`, `patches/**`, `.npmrc`, `.github/actions/setup-bun/**` and `test.yml`.
|
||||
- Tests do not wait for builds: workspace packages export their sources (`"./*": "./src/*.ts"` in `packages/core/package.json`), so a test must not read a dependency's `dist`. The build job's "Build workspace libraries" step checks the library builds on Linux.
|
||||
- A package that compiles `src` with tsc keeps colocated tests out of the build (`"exclude": ["src/**/*.test.ts"]` in `packages/util/tsconfig.build.json`). Otherwise `bun test` also runs the compiled copies, as Windows CI did with `packages/util/dist/retry.test.js`.
|
||||
|
||||
## When CI flakes
|
||||
|
||||
- Most suites print only failures (`--only-failures`): `(fail) <name> [5000ms]` and `^ this test timed out after 5000ms`.
|
||||
- When `killed 1 dangling process` and `# Unhandled error between tests` follow (for example `NpmInstallFailedError` with `SIGTERM` from a `git clone`), the test was still doing real work, not hanging.
|
||||
- The unhandled error is the abandoned Effect failing after Bun killed its child, not a second bug. Count the work (see Before you push) before choosing a fix.
|
||||
- Check whether the test also fails on dev pushes: `gh run list --workflow test.yml --branch dev --event push --status failure`. In one census before core's 30s default, 4 of 25 dev pushes went red, all on Windows unit tests (3 of them Npm.add 5s timeouts).
|
||||
- Read whole job logs with `gh run view --log --job <id>`; `--log-failed` omits green jobs, whose `Ran N tests across M files. [Xs]` lines you need for comparison.
|
||||
- Assert a child's stderr before its exit codes. `packages/core/test/util/effect-flock.test.ts:327-328` checks codes first, so its one CI failure never showed the cause.
|
||||
|
||||
## Before you push
|
||||
|
||||
Run from the package directory with the flags from its `test` script (app needs `--conditions` and `--preload`). In core use `bun run script/test.ts <file> …`, which forwards flags, isolates HOME and applies the 30s timeout.
|
||||
|
||||
- [ ] `bun test <file> --rerun-each 10` passes every time. Add `--randomize` for order dependence; reproduce a failure with `--seed=<n>`.
|
||||
- [ ] Per-test durations: `--reporter=junit --reporter-outfile=<abs path>` (works through `script/test.ts`). Any test over about 1s does real work you can name.
|
||||
- [ ] Windows: count spawns. `GIT_TRACE=<abs file>` logs roughly one `trace: built-in:` or `trace: exec:` line per git process; a logging `git` shim first on PATH also works. At about 40ms per spawn, compare with the package timeout.
|
||||
- [ ] Rerun the file while the full package suite runs in another terminal; CI runs three suites on four vCPUs.
|
||||
- [ ] Grep the diff for `Bun.sleep(`, `setTimeout(`, `Date.now() +`, `performance.now()`, `Effect.runPromise(` in product code, `mock.module`, `git config`, and literal port numbers.
|
||||
- [ ] After CI: with `--only-failures`, the `unit (windows)` log hides passing durations. Compare your package's `Ran N tests across M files. [Xs]` line with a recent dev run's.
|
||||
- [ ] New package suite: `bun turbo test --dry=json` lists `@opencode/<pkg>#test`.
|
||||
@@ -166,7 +166,6 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
- Before writing or changing tests, read the matching skill in `.opencode/skills`: `unit-tests` (bun test), `tui-tests` (OpenTUI renderer tests), `app-e2e-tests` (Playwright in `packages/app/e2e`) or `component-tests` (Storybook Playwright). They cover what has made tests slow or flaky in CI.
|
||||
|
||||
## Checks
|
||||
|
||||
|
||||
-127
@@ -2,133 +2,6 @@
|
||||
|
||||
Release notes for OpenCode V2, newest first.
|
||||
|
||||
## v2.0.30 — 2026-10-15
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Changed Files by Turn**: Enable **Settings → Preferences → Timeline → Changed files** to see each completed turn’s changed files, line counts, and expandable diffs. ([#55064](https://github.com/anomalyco/opencode/pull/55064))
|
||||
- **Docked TUI Diffs**: Run `/diff` inside a session to open the diff viewer beside your work. Press `f` to switch between the side panel and full screen; the layout adapts automatically to narrower terminals. ([#47152](https://github.com/anomalyco/opencode/pull/47152), [#55574](https://github.com/anomalyco/opencode/pull/55574), [#55780](https://github.com/anomalyco/opencode/pull/55780))
|
||||
- **Keyboard Tab Navigation**: Use **Option+↑/↓** to move between session tabs, or add **Shift** to jump between unread tabs. ([#54277](https://github.com/anomalyco/opencode/pull/54277))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Load local glob patterns and remote URLs configured through `instructions`, and refresh them when configuration changes. ([#55539](https://github.com/anomalyco/opencode/pull/55539))
|
||||
- Honor `OPENCODE_DISABLE_CLAUDE_CODE` and `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` when discovering `.claude` skills. ([#55774](https://github.com/anomalyco/opencode/pull/55774))
|
||||
- Publish a configuration schema that accepts both V1 and V2 configuration shapes during migration. ([#55524](https://github.com/anomalyco/opencode/pull/55524))
|
||||
- Remove the retired session-sharing backend and unavailable `/share` command. ([#55716](https://github.com/anomalyco/opencode/pull/55716))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Use protocol-specific inference endpoints for OpenCode providers and accept `null` namespaces in Responses API function calls. ([#55680](https://github.com/anomalyco/opencode/pull/55680), [#55631](https://github.com/anomalyco/opencode/pull/55631))
|
||||
- Fit compaction output limits to the request actually sent. ([#55613](https://github.com/anomalyco/opencode/pull/55613))
|
||||
- Restore authenticated TinyFish web search by initializing its MCP session. Thanks [@terry-an-investor](https://github.com/terry-an-investor). ([#54730](https://github.com/anomalyco/opencode/pull/54730))
|
||||
- Improve MCP compatibility by skipping unsupported catalog and codemode calls, advertising spec-compliant elicitation capabilities, and accepting `*.localhost` OAuth token endpoints. Thanks [@tsehla42](https://github.com/tsehla42) and [@donbowman](https://github.com/donbowman). ([#55474](https://github.com/anomalyco/opencode/pull/55474), [#55618](https://github.com/anomalyco/opencode/pull/55618), [#53196](https://github.com/anomalyco/opencode/pull/53196), [#55627](https://github.com/anomalyco/opencode/pull/55627))
|
||||
- Treat empty provider and model names as omitted, and substitute an empty value for missing configuration-file references. ([#55465](https://github.com/anomalyco/opencode/pull/55465), [#55388](https://github.com/anomalyco/opencode/pull/55388))
|
||||
- Default the Windows background service to port `45278`. ([#55547](https://github.com/anomalyco/opencode/pull/55547))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Open `/diff` beside the current session by default, retain state when toggling full screen with `f`, and adapt the file tree and patch layout to terminal width. ([#47152](https://github.com/anomalyco/opencode/pull/47152), [#55574](https://github.com/anomalyco/opencode/pull/55574), [#55780](https://github.com/anomalyco/opencode/pull/55780))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Show `/fork`’s pending view reliably and keep it aligned with the file list. ([#55746](https://github.com/anomalyco/opencode/pull/55746), [#55751](https://github.com/anomalyco/opencode/pull/55751))
|
||||
- Correct spacing and inactive key-hint styling in the docked diff panel. ([#55452](https://github.com/anomalyco/opencode/pull/55452), [#55467](https://github.com/anomalyco/opencode/pull/55467))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Show each completed turn’s changed files, line counts, and expandable diffs when **Settings → Preferences → Timeline → Changed files** is enabled. ([#55064](https://github.com/anomalyco/opencode/pull/55064))
|
||||
- Navigate session tabs with **Option+↑/↓**, or add **Shift** to jump to the previous or next unread tab. ([#54277](https://github.com/anomalyco/opencode/pull/54277))
|
||||
- Display delegations from the iOS app as **Voice** messages. ([#54426](https://github.com/anomalyco/opencode/pull/54426))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Open PowerShell in the selected project directory on Windows. ([#55710](https://github.com/anomalyco/opencode/pull/55710))
|
||||
- Keep project settings accessible when the project folder is missing. ([#55553](https://github.com/anomalyco/opencode/pull/55553))
|
||||
- Show the requested URL while `webfetch` is running. ([#55590](https://github.com/anomalyco/opencode/pull/55590))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Include MCP tool input as `metadata.input` in permission requests, allowing permission hooks to inspect the proposed call. Thanks [@lu-zero](https://github.com/lu-zero). ([#51062](https://github.com/anomalyco/opencode/pull/51062))
|
||||
|
||||
## v2.0.29 — 2026-10-14
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
|
||||
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
|
||||
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
|
||||
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
|
||||
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
|
||||
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
|
||||
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
|
||||
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
|
||||
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
|
||||
|
||||
## v2.0.28 — 2026-10-13
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/console-resource": "workspace:*",
|
||||
},
|
||||
@@ -273,7 +273,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -339,7 +339,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -383,9 +383,46 @@
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"effect": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"js-base64": "3.7.7",
|
||||
"luxon": "catalog:",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -401,7 +438,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@betteroffice/docx": "0.4.3",
|
||||
"@betteroffice/fonts": "0.4.0",
|
||||
@@ -450,7 +487,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
},
|
||||
@@ -469,7 +506,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -482,7 +519,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -496,7 +533,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -511,7 +548,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -548,7 +585,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -563,7 +600,7 @@
|
||||
},
|
||||
"packages/plugins/figma": {
|
||||
"name": "@opencode/plugin-figma",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"gray-matter": "4.0.3",
|
||||
},
|
||||
@@ -573,7 +610,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/plugin": "^2.0.27 || ^0.0.0-dev",
|
||||
"@opencode/plugin": "^2.0.27",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
@@ -593,7 +630,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -608,7 +645,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -632,7 +669,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -653,7 +690,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -676,7 +713,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -711,7 +748,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -731,7 +768,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -758,7 +795,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@planetscale/database": "1.19.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -776,7 +813,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/stats-core": "workspace:*",
|
||||
@@ -822,7 +859,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -836,7 +873,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -870,7 +907,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@kitlangton/solid-motion": "0.2.2",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -906,7 +943,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -944,7 +981,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -998,7 +1035,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1040,12 +1077,12 @@
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
@@ -1787,6 +1824,8 @@
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
|
||||
|
||||
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
|
||||
|
||||
"@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="],
|
||||
@@ -2011,6 +2050,8 @@
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="],
|
||||
|
||||
"@opencode/ai": ["@opencode/ai@workspace:packages/ai"],
|
||||
|
||||
"@opencode/app": ["@opencode/app@workspace:packages/app"],
|
||||
@@ -2033,6 +2074,8 @@
|
||||
|
||||
"@opencode/desktop": ["@opencode/desktop@workspace:packages/desktop"],
|
||||
|
||||
"@opencode/enterprise": ["@opencode/enterprise@workspace:packages/enterprise"],
|
||||
|
||||
"@opencode/files": ["@opencode/files@workspace:services/files"],
|
||||
|
||||
"@opencode/function": ["@opencode/function@workspace:packages/function"],
|
||||
@@ -2739,6 +2782,10 @@
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
||||
|
||||
"@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="],
|
||||
|
||||
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.4" } }, "sha512-/eUCx/6Ozq5grauwm/NqKtlW0oJ26b6GNesXrMuFID8WLg/qLEKf79Awfz9XrmyWxe7loD40K952r7AA5Oc23A=="],
|
||||
@@ -2943,6 +2990,8 @@
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
||||
|
||||
"@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
|
||||
@@ -3943,6 +3992,8 @@
|
||||
|
||||
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
@@ -4529,6 +4580,8 @@
|
||||
|
||||
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
|
||||
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
|
||||
@@ -4689,6 +4742,8 @@
|
||||
|
||||
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||
@@ -5715,6 +5770,12 @@
|
||||
|
||||
"@opencode/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
|
||||
"@opencode/enterprise/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"@opencode/enterprise/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode/gui-extensions/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
@@ -6435,6 +6496,10 @@
|
||||
|
||||
"@opencode/console-app/wrangler/workerd": ["workerd@1.20251118.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251118.0", "@cloudflare/workerd-darwin-arm64": "1.20251118.0", "@cloudflare/workerd-linux-64": "1.20251118.0", "@cloudflare/workerd-linux-arm64": "1.20251118.0", "@cloudflare/workerd-windows-64": "1.20251118.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Om5ns0Lyx/LKtYI04IV0bjIrkBgoFNg0p6urzr2asekJlfP18RqFzyqMFZKf0i9Gnjtz/JfAS/Ol6tjCe5JJsQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
|
||||
@@ -7193,6 +7258,32 @@
|
||||
|
||||
"@opencode/console-app/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251118.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2LV99AHSlpr8WcCb/BYbU2QsYkXLUL1izN6YKWkN9Eibv80JKX0RtgmD3dfmajE5sNvClavxZejgzVvHD9N9Ag=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="],
|
||||
@@ -7615,6 +7706,38 @@
|
||||
|
||||
"@opencode/console-app/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
@@ -7693,6 +7816,8 @@
|
||||
|
||||
"@astrojs/cloudflare/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||
|
||||
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
@@ -28,10 +28,6 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
|
||||
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 CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
|
||||
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
|
||||
properties: { value: `https://${domain}/console/api/internal/console-account` },
|
||||
})
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
@@ -52,8 +48,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
CONSOLE_CRM_TOKEN,
|
||||
CONSOLE_CRM_LOOKUP_URL,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SECRET } from "./secret"
|
||||
import { shortDomain } from "./stage"
|
||||
|
||||
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||
|
||||
new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
link: [SECRET.SupportApiKey],
|
||||
environment: {
|
||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||
OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value,
|
||||
OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value,
|
||||
OPENCODE_STORAGE_BUCKET: storage.name,
|
||||
},
|
||||
})
|
||||
@@ -5,6 +5,9 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
|
||||
}))
|
||||
|
||||
export const SECRET = {
|
||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { shortDomain } from "./stage"
|
||||
|
||||
// Retains existing share data until it is deleted separately.
|
||||
new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||
|
||||
// The Share worker takes over the hostname of the retired Teams worker. Cloudflare rejects a second custom domain for
|
||||
// the same hostname, so adopt the Teams domain and replace it, deleting the old one first.
|
||||
$transform(cloudflare.WorkersCustomDomain, (_args, opts, name) => {
|
||||
if (name !== "ShareDomain") return
|
||||
opts.aliases = [
|
||||
`urn:pulumi:${$app.stage}::${$app.name}::sst:cloudflare:SolidStart$sst:cloudflare:Worker$cloudflare:index/workersCustomDomain:WorkersCustomDomain::TeamsWorkerDomain`,
|
||||
]
|
||||
opts.deleteBeforeReplace = true
|
||||
opts.replaceOnChanges = ["*"]
|
||||
})
|
||||
|
||||
new sst.cloudflare.Worker("Share", {
|
||||
domain: shortDomain,
|
||||
handler: "packages/function/src/share.ts",
|
||||
})
|
||||
@@ -243,6 +243,17 @@ check_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
unbuffered_sed() {
|
||||
if echo | sed -u -e "" >/dev/null 2>&1; then
|
||||
sed -nu "$@"
|
||||
elif echo | sed -l -e "" >/dev/null 2>&1; then
|
||||
sed -nl "$@"
|
||||
else
|
||||
local pad="$(printf "\n%512s" "")"
|
||||
sed -ne "s/$/\\${pad}/" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
print_progress() {
|
||||
local bytes="$1"
|
||||
local length="$2"
|
||||
@@ -272,34 +283,52 @@ download_with_progress() {
|
||||
exec 4>/dev/null
|
||||
fi
|
||||
|
||||
local headers="${output}.headers"
|
||||
local tmp_dir=${TMPDIR:-/tmp}
|
||||
local basename="${tmp_dir}/opencode_install_$$"
|
||||
local tracefile="${basename}.trace"
|
||||
|
||||
rm -f "$tracefile"
|
||||
mkfifo "$tracefile"
|
||||
|
||||
# Hide cursor
|
||||
printf "\033[?25l" >&4
|
||||
|
||||
trap "trap - RETURN; rm -f \"$headers\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
|
||||
# Poll the file size on a timer so rendering cost stays fixed no matter how many bytes arrive.
|
||||
# Read the size with ls because busybox wc -c reads the whole file.
|
||||
curl -fsL -D "$headers" -o "$output" "$url" &
|
||||
(
|
||||
curl --trace-ascii "$tracefile" -fsL -o "$output" "$url"
|
||||
) &
|
||||
local curl_pid=$!
|
||||
|
||||
local length bytes
|
||||
while kill -0 "$curl_pid" 2>/dev/null; do
|
||||
if [ -f "$output" ]; then
|
||||
length=$(grep -i '^content-length:' "$headers" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "${length:-0}"
|
||||
fi
|
||||
sleep 0.1 2>/dev/null || sleep 1
|
||||
done
|
||||
unbuffered_sed \
|
||||
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
|
||||
-e '/^0000: content-length:/p' \
|
||||
-e '/^<= recv data/p' \
|
||||
"$tracefile" | \
|
||||
{
|
||||
local length=0
|
||||
local bytes=0
|
||||
|
||||
local ret=0
|
||||
wait "$curl_pid" || ret=$?
|
||||
if [ "$ret" -eq 0 ]; then
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "$bytes"
|
||||
fi
|
||||
while IFS=" " read -r -a line; do
|
||||
[ "${#line[@]}" -lt 2 ] && continue
|
||||
local tag="${line[0]} ${line[1]}"
|
||||
|
||||
if [ "$tag" = "0000: content-length:" ]; then
|
||||
length="${line[2]}"
|
||||
length=$(echo "$length" | tr -d '\r')
|
||||
bytes=0
|
||||
elif [ "$tag" = "<= recv" ]; then
|
||||
local size="${line[3]}"
|
||||
bytes=$(( bytes + size ))
|
||||
if [ "$length" -gt 0 ]; then
|
||||
print_progress "$bytes" "$length"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait $curl_pid
|
||||
local ret=$?
|
||||
echo "" >&4
|
||||
return $ret
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-foIqeQoc/G6w/9bd7OqC6mIxyQlCgbkPQWl3aHtb1l8=",
|
||||
"aarch64-linux": "sha256-wiiiSjLyLhn0OFnh1Ug2CdAfwtpkncaUPLUI0TIucdA=",
|
||||
"aarch64-darwin": "sha256-zQ26uuI0p+2tf7KYzcKLnPF+ERTumGz4uqezO9pObNg="
|
||||
"x86_64-linux": "sha256-Nrl3q4qV3YKGnSoKDjs0HvFyviVhtNfKEmMUIoREITc=",
|
||||
"aarch64-linux": "sha256-AfaHJIwoLA/9icnohKM2DL2dcmgSANfVT3oefOYBylE=",
|
||||
"aarch64-darwin": "sha256-nat5x4P4yGHqqBbw64CT0B66dmR3oSV3Qic1vMGnE4Q="
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -184,7 +184,6 @@
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
|
||||
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch"
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
import { NpmLibraries } from "@opencode/script/npm"
|
||||
import { Script } from "@opencode/script"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -10,20 +10,15 @@ async function published(name: string, version: string) {
|
||||
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||
}
|
||||
|
||||
await $`bun run build`
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const carried = await NpmLibraries.carried(tarball)
|
||||
|
||||
if (!carried) await $`bun run build`
|
||||
if (!NpmLibraries.packDir && (await published(pkg.name, pkg.version))) {
|
||||
if (await published(pkg.name, pkg.version)) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
} else if (carried) {
|
||||
await NpmLibraries.publish(carried)
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||
@@ -36,7 +31,7 @@ if (!NpmLibraries.packDir && (await published(pkg.name, pkg.version))) {
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||
try {
|
||||
await $`bun pm pack`
|
||||
await NpmLibraries.publish(tarball)
|
||||
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
)
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints(request.model)
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
|
||||
@@ -334,14 +334,14 @@ export const StreamItem = Schema.StructWithRest(
|
||||
id: Schema.optional(Schema.String),
|
||||
call_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
namespace: optionalNull(Schema.String),
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string; readonly namespace?: string }
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
@@ -946,8 +946,6 @@ const resolveItem = (
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
...item,
|
||||
// Some gateways serialize an absent namespace as null.
|
||||
namespace: item.namespace ?? undefined,
|
||||
id:
|
||||
item.id ??
|
||||
(index === undefined ? undefined : state.outputItems[index]) ??
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint, LLMRequest } from "../../schema/index.js"
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
import { newBreakpoints, ttlBucket } from "./cache.js"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
@@ -28,17 +28,14 @@ const CLAUDE_5M = [
|
||||
// Callers share the four-breakpoint budget across system, messages, and tools.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export const breakpoints = (model: LLMRequest["model"]) => {
|
||||
export const breakpoints = (modelID: string) => {
|
||||
// Substring matching also handles regional prefixes and model-bearing ARNs.
|
||||
const short = CLAUDE_5M.some((id) => model.id.includes(id))
|
||||
const short = CLAUDE_5M.some((id) => modelID.includes(id))
|
||||
return {
|
||||
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
|
||||
// An explicit Anthropic cache format opts opaque IDs such as application inference profile ARNs in.
|
||||
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
|
||||
// Other model families use implicit caching where available.
|
||||
supported:
|
||||
model.compatibility?.cacheFormat === "anthropic" ||
|
||||
(model.id.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => model.id.includes(id)))),
|
||||
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
|
||||
ttl1h: !short,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("opencode")
|
||||
const baseURL = "https://opencode.ai/inference/systemone/v1"
|
||||
const baseURL = "https://opencode.ai/inference/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/inference/systemone/v1/systemone",
|
||||
"url": "https://opencode.ai/inference/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LanguageModel, LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -116,53 +116,4 @@ describe("Bedrock Converse cache policy", () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
|
||||
|
||||
for (const ttlSeconds of [undefined, 3600]) {
|
||||
it.effect(
|
||||
`emits checkpoints for an opaque ARN declaring the Anthropic cache format (TTL: ${ttlSeconds ?? "default"})`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(bedrock.model(arn), { compatibility: { cacheFormat: "anthropic" } }),
|
||||
system: [
|
||||
{ type: "text", text: "Agent" },
|
||||
{ type: "text", text: "Project" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
prompt: "Question",
|
||||
cache:
|
||||
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
|
||||
}),
|
||||
)
|
||||
const marker = { cachePoint: ttlSeconds === undefined ? { type: "default" } : { type: "default", ttl: "1h" } }
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: arn,
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
|
||||
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
|
||||
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
|
||||
})
|
||||
if (ttlSeconds === undefined) expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const id of [arn, "amazon.nova-lite-v1:0"]) {
|
||||
it.effect(`ignores the Alibaba cache format for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(bedrock.model(id), { compatibility: { cacheFormat: "alibaba" } }),
|
||||
system: "System prefix",
|
||||
prompt: "Question",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -644,27 +644,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null function call namespace as absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", namespace: null }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
|
||||
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: "{}" },
|
||||
{ type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...item, arguments: "{}" }] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([expect.objectContaining({ id: "call_1", name: "lookup", input: {} })])
|
||||
expect(response.toolCalls[0]?.namespace).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
|
||||
+12
-11
@@ -39,7 +39,7 @@ bun run test:e2e:local
|
||||
bun run test:e2e:local -- --grep "settings"
|
||||
```
|
||||
|
||||
CI builds the app once and runs the same suite against a static Bun server (`e2e/utils/serve.ts`) that serves the production assets in `dist` with Vite preview's SPA fallback rules. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
|
||||
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
|
||||
|
||||
```bash
|
||||
bun run test:e2e:built
|
||||
@@ -64,23 +64,24 @@ Compiled CLI startup and service lifecycle coverage runs separately in CI via `p
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_BUILD=1` (build and serve `dist` locally; always enabled when `CI` is set)
|
||||
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
|
||||
- `PLAYWRIGHT_PORT` (managed dev or static server port, default: `3000`)
|
||||
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
|
||||
|
||||
## Deployment
|
||||
|
||||
The `deploy` GitHub Actions workflow uses SST to deploy the web app to `beta.opencode.ai` on every push to the
|
||||
`beta` branch in `anomalyco/opencode`. The `dev` and `production` stages do not deploy the web app.
|
||||
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
|
||||
|
||||
`beta` is the team's dogfooding branch: `dev` merged in by hand, plus commits pushed directly to it. Do not rebase or
|
||||
force-push it. Each push also runs the `publish` workflow, which ships the same commit as the OpenCode Beta desktop app
|
||||
(see `packages/desktop/README.md`).
|
||||
| Branch | Site |
|
||||
| ------------ | --------------------- |
|
||||
| `dev` | `app.dev.opencode.ai` |
|
||||
| `production` | `app.opencode.ai` |
|
||||
| `beta` | `beta.opencode.ai` |
|
||||
|
||||
The beta SST stage deploys only the web app, using the `WebApp` StaticSite definition in `infra/webapp.ts`. It sets
|
||||
the build channel and Sentry environment to `beta` without deploying the API, console, database, or billing
|
||||
infrastructure.
|
||||
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
|
||||
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
|
||||
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
|
||||
|
||||
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { expect, sourceURL, story } from "../../storybook/playwright/story"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import type { Command } from "@opencode/gui-extensions/sdk"
|
||||
import type { Item } from "../src/runtime/extension/host"
|
||||
|
||||
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
|
||||
|
||||
@@ -12,49 +9,21 @@ const modules = {
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
browser: source("../../gui-extensions/src/browser/index.ts"),
|
||||
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
|
||||
browserPanel: source("../../gui-extensions/src/browser/panel.tsx"),
|
||||
file: source("../../gui-extensions/src/file/index.ts"),
|
||||
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
|
||||
commands: source("../src/shell/commands/command.tsx"),
|
||||
sdk: source("../../gui-extensions/src/sdk/index.ts"),
|
||||
render: source("../src/runtime/extension/render.tsx"),
|
||||
settings: source("../src/settings/model.tsx"),
|
||||
platform: source("../src/runtime/platform/platform.tsx"),
|
||||
solid: source("./extension-host.fixture.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [
|
||||
{ mountBrowserRegion },
|
||||
host,
|
||||
panels,
|
||||
language,
|
||||
browser,
|
||||
file,
|
||||
commands,
|
||||
sdk,
|
||||
renderer,
|
||||
settings,
|
||||
platform,
|
||||
solid,
|
||||
] = await Promise.all([
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
import(modules.commands),
|
||||
import(modules.sdk),
|
||||
import(modules.render),
|
||||
import(modules.settings),
|
||||
import(modules.platform),
|
||||
import(modules.solid),
|
||||
// Load the pane before mounting: Vite must not reload the page to optimize its dependencies mid-fixture.
|
||||
import(modules.browserPanel),
|
||||
])
|
||||
|
||||
mountBrowserRegion({
|
||||
@@ -66,87 +35,10 @@ story.beforeEach(async ({ mount, page }) => {
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
Shell: (props: ParentProps) =>
|
||||
solid.createComponent(platform.PlatformProvider, {
|
||||
value: { platform: "web", openExternal() {}, restart: async () => undefined, notify: async () => undefined },
|
||||
get children() {
|
||||
return solid.createComponent(settings.SettingsProvider, {
|
||||
get children() {
|
||||
return solid.createComponent(commands.CommandProvider, {
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
Commands: (props: { close: () => void }) => {
|
||||
const command = commands.useCommand()
|
||||
const extensions = host.useExtensionHost()
|
||||
command.register("fixture-session", () => [
|
||||
{
|
||||
id: "tab.close",
|
||||
title: "Close session tab",
|
||||
keybind: "mod+w",
|
||||
onSelect: props.close,
|
||||
},
|
||||
])
|
||||
// Storybook aliases the app's command hook to a no-op. Bridge the real extension contributions
|
||||
// to the real dispatcher imported by source URL, so the shortcut runs rather than its alias.
|
||||
command.register("fixture-extensions", () =>
|
||||
extensions.items(sdk.Command).map((item: Item<Command>) => {
|
||||
const scope = item.value.scope
|
||||
|
||||
return {
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
keybind: item.value.bind,
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: scope
|
||||
? (event: KeyboardEvent) => event.target instanceof Element && !!event.target.closest(scope)
|
||||
: undefined,
|
||||
onSelect: () => item.value.run(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return null
|
||||
},
|
||||
Contribution: renderer.Contribution,
|
||||
})
|
||||
}, modules)
|
||||
})
|
||||
|
||||
story("closes a blank browser tab from its address field without closing the session", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const outside = root.getByRole("button", { name: "Beta", exact: true })
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Prove the scope while browser.close is still registered.
|
||||
await outside.focus()
|
||||
await outside.press("ControlOrMeta+w")
|
||||
await expect(root.getByTestId("session-state")).toHaveText("closed")
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText("[]")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("example.com")
|
||||
await expect(address).toBeFocused()
|
||||
await address.press("ControlOrMeta+w")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveCount(0)
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText('["tab_33333333-3333-3333-3333-333333333333"]')
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
})
|
||||
|
||||
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
|
||||
@@ -22,8 +22,6 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
|
||||
alternate === "none" ? 0 : 1,
|
||||
)
|
||||
// A late Inter swap resizes the delivery keybind and shifts the toolbar; measure after fonts settle.
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
|
||||
for (const width of [1024, 360]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
@@ -112,7 +110,6 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
await add.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(add).toBeFocused()
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
}
|
||||
|
||||
@@ -371,59 +368,3 @@ story("keeps default thinking accessible by keyboard without composer hover", as
|
||||
await expect(control).not.toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
|
||||
story.describe("mobile variant drawer", () => {
|
||||
story.use({ viewport: { width: 360, height: 720 } })
|
||||
|
||||
// Guards patches/@corvu%2Fdrawer@0.2.4.patch. The drawer starts its opening transition two frames after it opens.
|
||||
// Dismissing it before then used to slide it open anyway, and dismissing it in that frame left it closing off screen
|
||||
// for good, holding focus and disabling pointer input on the page. Playwright input cannot target those frames, so
|
||||
// the page dismisses it.
|
||||
for (const moment of ["before", "as"] as const) {
|
||||
story(`closes and restores focus when dismissed ${moment} it starts opening`, async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const variant = component.getByRole("button", { name: "Choose model variant" })
|
||||
const drawer = page.getByRole("dialog", { name: "Choose model variant", exact: true })
|
||||
const high = drawer.getByRole("button", { name: "high", exact: true })
|
||||
|
||||
// Load the lazy drawer first so the dismissal below races only the drawer's own transition.
|
||||
await variant.click()
|
||||
await expect(high).toBeInViewport()
|
||||
const content = await drawer.elementHandle()
|
||||
|
||||
if (!content) throw new Error("Missing variant drawer")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(variant).toBeFocused()
|
||||
|
||||
await variant.evaluate(
|
||||
(trigger: HTMLElement, race) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const dismiss = () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
|
||||
resolve()
|
||||
}
|
||||
|
||||
// Mutation records arrive before the browser renders the opening transition's first frame.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!race.content.hasAttribute("data-opening")) return
|
||||
observer.disconnect()
|
||||
dismiss()
|
||||
})
|
||||
|
||||
if (race.moment === "as") observer.observe(race.content, { attributeFilter: ["data-opening"] })
|
||||
trigger.click()
|
||||
|
||||
if (race.moment === "before") dismiss()
|
||||
}),
|
||||
{ content, moment },
|
||||
)
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(variant).toBeFocused()
|
||||
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none")
|
||||
|
||||
await variant.click()
|
||||
await expect(high).toBeInViewport()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,187 +1,19 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("resizes the dock to fit different answer counts", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-answer-counts")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const next = question.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
|
||||
await expect(next).toBeEnabled()
|
||||
|
||||
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
|
||||
const initial = await height()
|
||||
await next.click()
|
||||
await expect.poll(height).toBeGreaterThan(initial)
|
||||
await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible()
|
||||
|
||||
const expanded = await height()
|
||||
const back = question.getByRole("button", { name: "Back", exact: true })
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
await expect.poll(height).toBeLessThan(expanded)
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible()
|
||||
})
|
||||
|
||||
story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => {
|
||||
story("shows question navigation shortcuts", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const wide = question.getByRole("radio", { name: /Wide/ })
|
||||
const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const next = question.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(focused).toBeFocused()
|
||||
|
||||
await focused.press("2")
|
||||
await expect(wide).toBeChecked()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(wide).toBeFocused()
|
||||
await next.press("1")
|
||||
await expect(focused).toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
await focused.press("3")
|
||||
await expect(customRadio).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
await input.press("Enter")
|
||||
await expect(customRadio).toBeFocused()
|
||||
await customRadio.press("2")
|
||||
await expect(wide).toBeChecked()
|
||||
await expect(customRadio).not.toBeChecked()
|
||||
|
||||
await next.click()
|
||||
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
|
||||
await expect(files).toBeFocused()
|
||||
await files.press("1")
|
||||
await expect(files).toBeChecked()
|
||||
await files.press("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await terminal.press("2")
|
||||
await expect(terminal).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
|
||||
await terminal.press("4")
|
||||
await expect(customCheckbox).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).not.toBeChecked()
|
||||
await input.press("Enter")
|
||||
await expect(customCheckbox).toBeFocused()
|
||||
await customCheckbox.press("4")
|
||||
await expect(customCheckbox).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const wide = question.getByRole("radio", { name: /Wide/ })
|
||||
await expect(focused).toBeFocused()
|
||||
await focused.press("1")
|
||||
await focused.press("9")
|
||||
await focused.press("Alt+2")
|
||||
await expect(focused).toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
|
||||
await question.getByRole("button", { name: "Next", exact: true }).click()
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused()
|
||||
await page.keyboard.down("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await page.keyboard.down("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await page.keyboard.up("2")
|
||||
})
|
||||
|
||||
story("types digits into the custom answer without selecting numbered options", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
await expect(focused).toBeFocused()
|
||||
await focused.press("3")
|
||||
await expect(input).toBeFocused()
|
||||
await input.press("1")
|
||||
await input.press("2")
|
||||
await input.press("3")
|
||||
await expect(input).toHaveValue("123")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
|
||||
const shortcut = mac ? "⌘⏎" : "Ctrl+⏎"
|
||||
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
|
||||
const next = component.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
|
||||
await next.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText("Next")
|
||||
await expect(page.getByRole("tooltip")).toContainText(shortcut)
|
||||
await next.press(mac ? "Meta+Enter" : "Control+Enter")
|
||||
const submit = component.getByRole("button", { name: "Submit", exact: true })
|
||||
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
|
||||
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
|
||||
await submit.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText("Submit")
|
||||
await expect(page.getByRole("tooltip")).toContainText(shortcut)
|
||||
await next.click()
|
||||
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
|
||||
const back = component.getByRole("button", { name: "Back", exact: true })
|
||||
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
|
||||
await expect(back).toHaveText("Back")
|
||||
await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft")
|
||||
await back.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText(mac ? "⌘[" : "Alt+←")
|
||||
await back.press(mac ? "Meta+[" : "Alt+ArrowLeft")
|
||||
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
|
||||
await page.keyboard.press(
|
||||
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
|
||||
)
|
||||
await expect(next).toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const row = question.locator('[data-slot="question-option"][data-custom="true"]')
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
|
||||
await expect(input).toBeEditable()
|
||||
await expect(input).toHaveValue("")
|
||||
const node = await input.evaluateHandle((element) => element)
|
||||
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
|
||||
const initial = await height()
|
||||
const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line"
|
||||
|
||||
const predefined = question.getByRole("radio", { name: /Focused/ })
|
||||
await predefined.click()
|
||||
await input.focus()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(predefined).toBeChecked()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await row.click({ position: { x: 4, y: 4 } })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(predefined).not.toBeChecked()
|
||||
await input.fill(draft)
|
||||
await expect(input).toHaveValue(draft)
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(predefined).not.toBeChecked()
|
||||
await expect.poll(height).toBe(initial)
|
||||
await input.press("Enter")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(input).toHaveValue(draft)
|
||||
await row.click({ position: { x: 30, y: 20 } })
|
||||
await expect(input).toBeFocused()
|
||||
await expect(input).toHaveValue(draft)
|
||||
await expect
|
||||
.poll(() => input.evaluate((element, original) => element === original && original.isConnected, node))
|
||||
.toBe(true)
|
||||
await node.dispose()
|
||||
})
|
||||
@@ -48,15 +48,6 @@ test("lists recent sessions before a query is entered", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("reopens from its shortcut as soon as it closes", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
await input.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
// A closed dialog stops blocking shortcuts when it leaves the screen, not when it is disposed afterwards.
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
})
|
||||
|
||||
test("appends search results without resetting the selected command", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const files = Promise.withResolvers<void>()
|
||||
|
||||
@@ -4,89 +4,6 @@ import { openDraft, openSession } from "../utils/workspace"
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test("a catalog refresh cannot replace an existing session's model", async ({ page }) => {
|
||||
const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } }
|
||||
const beta = { id: "beta", name: "Beta" }
|
||||
const catalog = { current: provider(alpha, beta) }
|
||||
const switches: unknown[] = []
|
||||
const prompts: unknown[] = []
|
||||
const sessionID = "ses_catalog_selection"
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/model`)
|
||||
switches.push(request.postDataJSON())
|
||||
})
|
||||
|
||||
const view = await openSession(page, {
|
||||
name: "CatalogSelection",
|
||||
sessions: [{ id: sessionID, agent: "build", model: { id: "alpha", providerID: "opencode", variant: "high" } }],
|
||||
provider: () => catalog.current,
|
||||
onPrompt: (input) => void prompts.push(input.body),
|
||||
seed: { storage: { "opencode.global.dat:model": { recent: [{ providerID: "opencode", modelID: "beta" }] } } },
|
||||
})
|
||||
|
||||
const control = page.locator('[data-action="composer-model"]')
|
||||
await expect(control).toHaveText("Alpha")
|
||||
await view.editor.fill("Keep my selected model")
|
||||
|
||||
catalog.current = provider(beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_missing",
|
||||
created: T0 + 1,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Select model")
|
||||
await view.editor.press("Enter")
|
||||
await expect(page.getByText("Select an agent and model", { exact: true })).toBeVisible()
|
||||
await expect(view.editor).toHaveText("Keep my selected model")
|
||||
expect(switches).toEqual([])
|
||||
expect(prompts).toEqual([])
|
||||
|
||||
catalog.current = provider(alpha, beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_restored",
|
||||
created: T0 + 2,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Alpha")
|
||||
await expect(page.getByRole("button", { name: "Choose model variant", exact: true })).toHaveText("high")
|
||||
await view.editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(switches).toEqual([{ model: { id: "alpha", providerID: "opencode", variant: "high" } }])
|
||||
|
||||
catalog.current = provider(beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_removed",
|
||||
created: T0 + 3,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Select model")
|
||||
await control.click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Select model" })
|
||||
.getByRole("button", { name: "Beta Free", exact: true })
|
||||
.click()
|
||||
await expect(control).toHaveText("Beta")
|
||||
await view.editor.fill("Use the replacement I chose")
|
||||
await view.editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(2)
|
||||
expect(switches).toEqual([
|
||||
{ model: { id: "alpha", providerID: "opencode", variant: "high" } },
|
||||
{ model: { id: "beta", providerID: "opencode" } },
|
||||
])
|
||||
})
|
||||
|
||||
async function draft(page: Page) {
|
||||
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
|
||||
await editor.click()
|
||||
@@ -404,57 +321,6 @@ for (const row of [
|
||||
})
|
||||
}
|
||||
|
||||
test("marks a Console provider with its workspace only when it is also connected directly", async ({ page }) => {
|
||||
const model = (id: string, name: string) => ({ [id]: { id, name, limit: { context: 200_000 } } })
|
||||
|
||||
const managed = (id: string, canonical: string, name: string, models: ReturnType<typeof model>) => ({
|
||||
id,
|
||||
canonical,
|
||||
integrationID: "opencode",
|
||||
name: `Anomaly / ${name}`,
|
||||
models,
|
||||
})
|
||||
|
||||
await openSession(page, {
|
||||
name: "ComposerConsoleProviders",
|
||||
provider: {
|
||||
all: [
|
||||
managed("opencode", "opencode", "OpenCode", model("console-model", "Console Model")),
|
||||
managed("console-google", "google", "Google", model("managed-gemini", "Managed Gemini")),
|
||||
managed("console-openai", "openai", "OpenAI", model("managed-gpt", "Managed GPT")),
|
||||
{ id: "google", canonical: "google", name: "Google", models: model("direct-gemini", "Direct Gemini") },
|
||||
],
|
||||
connected: ["opencode", "console-google", "console-openai", "google"],
|
||||
default: { providerID: "opencode", modelID: "console-model" },
|
||||
},
|
||||
seed: {
|
||||
storage: {
|
||||
"opencode.global.dat:model": {
|
||||
user: [
|
||||
["opencode", "console-model"],
|
||||
["console-google", "managed-gemini"],
|
||||
["console-openai", "managed-gpt"],
|
||||
["google", "direct-gemini"],
|
||||
].map(([providerID, modelID]) => ({ providerID, modelID, visibility: "show" })),
|
||||
recent: [],
|
||||
variant: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await page.locator('[data-component="composer"] [data-action="composer-model"]').click()
|
||||
const menu = page.getByRole("menu")
|
||||
await expect(menu.getByRole("menuitemradio", { name: "Direct Gemini", exact: true })).toBeVisible()
|
||||
const headings = menu.locator('[data-slot="menu-v2-group-label"]')
|
||||
await expect(headings).toHaveCount(4)
|
||||
await expect(headings.filter({ hasText: /^GoogleAnomaly$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^Google$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^OpenAI$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^OpenCode$/ })).toHaveCount(1)
|
||||
await expect(menu.getByText(/Anomaly \//)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
|
||||
const { editor: input } = await openSession(page, {
|
||||
name: "ComposerThinking",
|
||||
|
||||
@@ -86,9 +86,7 @@ test("Git initialization belongs to its session and refreshes after a session sw
|
||||
await expect(pending).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a rejected Git init releases its session's button without showing an error in another session", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => {
|
||||
await openSession(page, {
|
||||
name: "ReviewInitFailure",
|
||||
project: { vcs: undefined },
|
||||
@@ -145,7 +143,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const openFile = panel.getByRole("button", { name: "Open file" })
|
||||
const tab = (name: string) => panel.getByRole("tab", { name, exact: true })
|
||||
await contextButton.click()
|
||||
@@ -183,7 +181,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
|
||||
await workspace.push([filesystemEvent(directory, "README.md", "unlink")])
|
||||
await missingResponse
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
await expect(tab("File not found: README.md")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -311,7 +309,7 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
})
|
||||
const panel = page.locator("#review-panel")
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const context = panel.getByRole("tab", { name: "Context", exact: true })
|
||||
|
||||
await contextButton.click()
|
||||
@@ -335,83 +333,6 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("context hides free-model cost but keeps paid-model zero cost", async ({ page }) => {
|
||||
const cases = [
|
||||
{ id: "ses_context_free", title: "Free model", model: "free", cost: 0, usage: false, text: "0" },
|
||||
{ id: "ses_context_paid", title: "Paid model", model: "paid", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_used", title: "Paid usage", model: "paid", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_free_used", title: "Free usage", model: "free", cost: 0, usage: true, text: "2k" },
|
||||
{ id: "ses_context_mixed", title: "Previously paid", model: "free", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_unknown", title: "Unknown pricing", model: "missing", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_output", title: "Output pricing", model: "output", cost: 0, usage: false, text: "0·$0.00" },
|
||||
// The root's model is free, but its subagent spent on a paid model: the family total shows.
|
||||
{ id: "ses_context_family", title: "Paid subagent", model: "free", cost: 0, usage: true, text: "2k·$1.25" },
|
||||
// A completed compaction resets the context until the next reply reports usage, as in the TUI.
|
||||
{ id: "ses_context_compacted", title: "Compacted", model: "paid", cost: 1.25, usage: true, text: "0·$1.25" },
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "ContextPricing",
|
||||
provider: provider(
|
||||
{ id: "free", name: "Free", cost: { input: 0, output: 0 } },
|
||||
{ id: "paid", name: "Paid", cost: { input: 1, output: 3 } },
|
||||
{ id: "output", name: "Output", cost: { input: 0, output: 1 } },
|
||||
),
|
||||
sessions: [
|
||||
...cases.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
cost: item.cost,
|
||||
})),
|
||||
{
|
||||
id: "ses_context_family_child",
|
||||
parentID: "ses_context_family",
|
||||
title: "Subagent run",
|
||||
model: { id: "paid", providerID: "opencode" },
|
||||
cost: 1.25,
|
||||
},
|
||||
],
|
||||
pageMessages: (id) => ({
|
||||
items: cases.flatMap((item): SessionMessageInfo[] =>
|
||||
item.id === id && item.usage
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
tokens: { input: 1500, output: 500, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...(item.id === "ses_context_compacted"
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}_compaction`,
|
||||
type: "compaction" as const,
|
||||
status: "completed" as const,
|
||||
reason: "manual" as const,
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
})
|
||||
const button = page.getByRole("button", { name: "Toggle session context", exact: true })
|
||||
|
||||
for (const item of cases) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: item.title }).click()
|
||||
await expectSessionTitle(page, item.title)
|
||||
await expect(button).toHaveText(item.text)
|
||||
}
|
||||
})
|
||||
|
||||
test("file tree expands Windows paths and scrolls long names in both directions", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
|
||||
@@ -572,155 +493,11 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
|
||||
|
||||
// A restored file tab whose file was deleted shows its inline not-found state without a toast; clicking a broken link
|
||||
// when no tab is open for it shows the toast.
|
||||
await page.route("**/api/fs/read/shopping.txt*", (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
// SAFETY: the server's wire body for a missing file, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "FileNotFoundError", path: "shopping.txt", message: "File not found: shopping.txt" },
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const missingTab = panel.getByRole("tab", { name: "File not found: shopping.txt", exact: true })
|
||||
await expect(missingTab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("File not found: shopping.txt", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
|
||||
|
||||
await missingTab.locator("..").getByRole("button", { name: "Close tab" }).click()
|
||||
await expect(missingTab).toHaveCount(0)
|
||||
await link.click()
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
await expect(missingTab).toHaveCount(0)
|
||||
})
|
||||
|
||||
/** What the image case's frame probe records on the window. */
|
||||
type ImageProbe = { problems: string[]; frames: number; running: boolean }
|
||||
|
||||
test("image gestures anchor zoom, pan, and reset independently of the pane", async ({ page }) => {
|
||||
// Some browsers expose no natural dimensions for a viewBox-only SVG.
|
||||
await page.addInitScript(() => {
|
||||
for (const name of ["naturalWidth", "naturalHeight"]) {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, name)!
|
||||
Object.defineProperty(HTMLImageElement.prototype, name, {
|
||||
...original,
|
||||
get() {
|
||||
return this.alt === "second.svg" ? 0 : original.get?.call(this)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
await openSession(page, {
|
||||
name: "ImageGestures",
|
||||
fileList: () => [],
|
||||
fileContent: (path) => ({
|
||||
content:
|
||||
path === "second.svg"
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>',
|
||||
}),
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
id: "msg_images",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
content: [{ type: "text", text: "[First](first.svg) [Second](second.svg)" }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
await page.getByRole("link", { name: "First", exact: true }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
const image = panel.getByRole("img", { name: "first.svg", exact: true })
|
||||
const stage = panel.locator('[data-slot="artifact-stage"]')
|
||||
const fit = panel.getByRole("button", { name: "Fit", exact: true })
|
||||
await expect(image).toBeVisible()
|
||||
await expect(fit).toBeDisabled()
|
||||
// Wait for the side pane's opening motion before choosing a gesture anchor.
|
||||
await image.hover()
|
||||
const initial = (await image.boundingBox())!
|
||||
const pane = (await panel.boundingBox())!
|
||||
const anchor = { x: Math.round(initial.x + initial.width * 0.55), y: Math.round(initial.y + initial.height * 0.55) }
|
||||
await page.mouse.move(anchor.x, anchor.y)
|
||||
// A continuous pinch catches anchor drift while the image still fits one axis.
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Input.synthesizePinchGesture", {
|
||||
...anchor,
|
||||
scaleFactor: 2,
|
||||
relativeSpeed: 500,
|
||||
gestureSourceType: "mouse",
|
||||
})
|
||||
await cdp.detach()
|
||||
await expect(fit).toBeEnabled()
|
||||
const zoomed = (await image.boundingBox())!
|
||||
expect(zoomed.width).toBeCloseTo(initial.width * 2, 1)
|
||||
expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo((anchor.x - initial.x) / initial.width, 4)
|
||||
expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo((anchor.y - initial.y) / initial.height, 4)
|
||||
expect(await panel.boundingBox()).toEqual(pane)
|
||||
expect(await page.evaluate(() => window.visualViewport?.scale)).toBe(1)
|
||||
await page.mouse.wheel(40, 60)
|
||||
await expect.poll(async () => (await image.boundingBox())!.x).toBeCloseTo(zoomed.x - 40, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(zoomed.y - 60, 1)
|
||||
const scrolled = (await image.boundingBox())!
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(anchor.x + 60, anchor.y + 40, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await expect(fit).toBeEnabled()
|
||||
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 60, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 40, 1)
|
||||
// Touch uses the same captured-pointer path and must not generate a click-to-fit.
|
||||
const touch = await page.context().newCDPSession(page)
|
||||
await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: anchor.x, y: anchor.y }] })
|
||||
await touch.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: anchor.x - 30, y: anchor.y - 20 }],
|
||||
})
|
||||
await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await touch.detach()
|
||||
await expect(fit).toBeEnabled()
|
||||
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 30, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 20, 1)
|
||||
// Keyboard-accessible fit restores the exact original geometry.
|
||||
await fit.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(fit).toBeDisabled()
|
||||
expect(await image.boundingBox()).toEqual(initial)
|
||||
expect(
|
||||
await stage.evaluate((element) =>
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: 40, bubbles: true, cancelable: true })),
|
||||
),
|
||||
).toBe(true)
|
||||
await image.click()
|
||||
await expect(fit).toBeEnabled()
|
||||
await page.getByRole("link", { name: "Second", exact: true }).click()
|
||||
await expect(panel.getByRole("img", { name: "second.svg", exact: true })).toBeVisible()
|
||||
await expect(fit).toBeDisabled()
|
||||
await panel.getByRole("img", { name: "second.svg", exact: true }).hover()
|
||||
const second = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
|
||||
expect(second.width).toBeGreaterThan(0)
|
||||
expect(second.height).toBeGreaterThan(0)
|
||||
await page.keyboard.down("Control")
|
||||
await page.mouse.wheel(0, -50)
|
||||
await expect(fit).toBeEnabled()
|
||||
await page.keyboard.up("Control")
|
||||
await fit.click()
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const frame = (await stage.boundingBox())!
|
||||
const media = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
|
||||
|
||||
return media.width <= frame.width - 47 && media.height <= frame.height - 47
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test("image files load into the review panel and keep it painted while loading", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 960, height: 900 })
|
||||
const directory = "C:/OpenCode/ReviewImage"
|
||||
@@ -814,23 +591,17 @@ test("image files load into the review panel and keep it painted while loading",
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("video files over 25 MB play in the viewer, expand into the theatre overlay, and collapse on Escape without remounting", async ({
|
||||
test("video files expand into an in-window theatre overlay and collapse on Escape without remounting", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = "C:/OpenCode/ReviewVideo"
|
||||
const file = "arcade-update-draft.mp4"
|
||||
const file = "demo.webm"
|
||||
|
||||
const head = Buffer.from(
|
||||
"AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAMm1kYXSCSYNCAAPwAjYAOCQcGEIAADBgAABYS///mpmB/////7FMC////+OkgAAAAAL+bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAil0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAkAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAGhbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABTG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAQxzdGJsAAAAqHN0c2QAAAAAAAAAAQAAAJh2cDA5AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAJABIAAAASAAAAAAAAAABF0xhdmM2My4xLjEwMiBsaWJ2cHgtdnA5AAAAAAAAAAAAGP//AAAAFHZwY0MBAAAAAAqCAgICAAAAAAAKZmllbAEAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAJxAAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAEAAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAACoAAAABAAAAFHN0Y28AAAAAAAAAAQAAACwAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAy",
|
||||
const webm = Buffer.from(
|
||||
"GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAH5EU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEyTbuMU6uEHFO7a1OsggHj7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxMYXZmNjMuMS4xMDJXQYxMYXZmNjMuMS4xMDJEiYhAj0AAAAAAABZUrmvXrgEAAAAAAABO14EBc8WIiQeUklk95R2cgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4Q7msoA4JCwgUC6gSSagQJVsIRVuYEBVe6BAOwBAAAAAAAAAgAAElTDZ/pzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYzLjEuMTAyc3PVY8CLY8WIiQeUklk95R1nyKBFo4dFTkNPREVSRIeTTGF2YzYzLjEuMTAyIGxpYnZweGfIoUWjiERVUkFUSU9ORIeTMDA6MDA6MDEuMDAwMDAwMDAwAB9DtnWt54EAo6iBAACAsAIAnQEqQAAkAABHCIWFiJmEiAICAAaOT8zHm/FYAP7/q1CAHFO7a5G7j7OBALeK94EB8YIBsfCBAw==",
|
||||
"base64",
|
||||
)
|
||||
|
||||
const mp4 = Buffer.alloc(38_400_000)
|
||||
|
||||
head.copy(mp4)
|
||||
mp4.writeUInt32BE(mp4.length - head.length, head.length)
|
||||
mp4.write("free", head.length + 4, "ascii")
|
||||
|
||||
await openSession(page, {
|
||||
name: "ReviewVideo",
|
||||
fileList: (path) => (path ? [] : [fileNode(directory, file)]),
|
||||
@@ -842,7 +613,7 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { "access-control-allow-origin": "*", "content-type": "application/octet-stream" },
|
||||
body: mp4,
|
||||
body: webm,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -851,17 +622,12 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
|
||||
await panel.getByRole("button", { name: file }).click()
|
||||
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const toolbar = panel.locator('[data-slot="artifact-toolbar"]')
|
||||
const stage = panel.locator('[data-slot="artifact-stage"]')
|
||||
const video = stage.locator('video[data-slot="artifact-media"]')
|
||||
const fullscreen = stage.getByRole("button", { name: "Enter full screen", exact: true })
|
||||
const exitFullscreen = stage.getByRole("button", { name: "Exit full screen", exact: true })
|
||||
|
||||
await expect(video).toBeVisible()
|
||||
await expect(toolbar).toContainText("64 × 36")
|
||||
await expect(toolbar).toContainText("0:01")
|
||||
await expect(toolbar).toContainText("38.4 MB")
|
||||
await expect(panel.getByText(/Binary file/)).toHaveCount(0)
|
||||
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
|
||||
const inlineBounds = (await stage.boundingBox())!
|
||||
expect(inlineBounds.width).toBeLessThan(1000)
|
||||
@@ -890,12 +656,6 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
|
||||
await page.mouse.click(8, 8)
|
||||
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
|
||||
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
|
||||
|
||||
// Chromium's controls also answer a double click with native fullscreen, which covers the backdrop. Opting out with
|
||||
// controlslist="nofullscreen" would end their click-to-play as well.
|
||||
await video.click()
|
||||
await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)).toBeGreaterThan(0)
|
||||
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull()
|
||||
})
|
||||
|
||||
test("restores review state and the side-panel tab per session", async ({ page }) => {
|
||||
@@ -947,7 +707,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await page.getByRole("button", { name: "beta.ts" }).click()
|
||||
await selectedFile("beta.ts")
|
||||
await page.getByRole("button", { name: "Toggle session context" }).click()
|
||||
await page.getByRole("button", { name: "View context usage" }).click()
|
||||
await selectedTab("Context")
|
||||
|
||||
await switchSession("Gamma review state")
|
||||
|
||||
@@ -224,15 +224,8 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
|
||||
await expect(view.input).toHaveText("")
|
||||
})
|
||||
|
||||
test("a steer shows as pending in the timeline as the composer clears, before its selection commits", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The running execution has delivered input, so a new steer waits behind its work.
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
|
||||
)
|
||||
|
||||
test("a steer shows in the timeline as the composer clears, before its selection commits", async ({ page }) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "steer")
|
||||
const commit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/model`, { method: "POST" })
|
||||
|
||||
@@ -240,21 +233,16 @@ test("a steer shows as pending in the timeline as the composer clears, before it
|
||||
hasText: "steer while the model commits",
|
||||
})
|
||||
|
||||
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
|
||||
await view.input.fill("steer while the model commits")
|
||||
await view.input.press("Enter")
|
||||
await commit.arrived
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
|
||||
// It turns pending at once rather than fading from the delivered color it may have rendered with first.
|
||||
await expect(prompt.locator('[data-slot="user-message-text"]')).toHaveCSS("transition-duration", "0s")
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
commit.release()
|
||||
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["steer while the model commits"])
|
||||
await expect(prompt).toHaveCount(1)
|
||||
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
|
||||
})
|
||||
|
||||
test("a steer into an idle session leaves the finished turn finished until delivery", async ({ page }) => {
|
||||
|
||||
@@ -83,93 +83,6 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
|
||||
})
|
||||
|
||||
test("replies with predefined choices and the edited custom multiselect answer", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
forms: [
|
||||
{
|
||||
id: "frm_question_multiselect",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "multiselect",
|
||||
title: "Include",
|
||||
description: "Which supporting surfaces should remain visible?",
|
||||
options: [
|
||||
{ value: "files", label: "Changed files" },
|
||||
{ value: "terminal", label: "Terminal" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
const custom = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const cancellations: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect`
|
||||
)
|
||||
cancellations.push(request.url())
|
||||
})
|
||||
|
||||
await expect(files).not.toBeChecked()
|
||||
await files.click()
|
||||
await input.fill("Original custom answer")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await terminal.click()
|
||||
await expect(terminal).toBeChecked()
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
|
||||
await custom.click()
|
||||
await expect(custom).not.toBeChecked()
|
||||
await expect(input).toHaveValue("Original custom answer")
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
await input.focus()
|
||||
await expect(custom).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
await custom.click()
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(input).toHaveValue("Original custom answer")
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
|
||||
await input.fill("Updated custom answer")
|
||||
await input.press("Escape")
|
||||
await expect(input).not.toBeFocused()
|
||||
await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeEnabled()
|
||||
await expect(input).toHaveValue("Updated custom answer")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
expect(cancellations).toEqual([])
|
||||
|
||||
const reply = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect/reply`,
|
||||
)
|
||||
|
||||
await question.getByRole("button", { name: "Submit", exact: true }).press("Control+Enter")
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: ["files", "terminal", "Updated custom answer"] } })
|
||||
expect(cancellations).toEqual([])
|
||||
})
|
||||
|
||||
test("shows a pending permission dock", async ({ page }) => {
|
||||
const replies: { sessionID: string; permissionID: string; body: unknown }[] = []
|
||||
await mockServer(page, {
|
||||
|
||||
@@ -18,11 +18,6 @@ for (const custom of [false, true]) {
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(trigger).toBeEnabled()
|
||||
// The trigger scrolls with the timeline under its sticky header, and Kobalte closes a tooltip whenever its
|
||||
// trigger scrolls. A cold timeline keeps scrolling to its bottom until it reveals its rows, so hover after that.
|
||||
await expect(
|
||||
page.locator(`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.sourceMessageIDs.at(-1)}"]`),
|
||||
).toBeVisible()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
await expect(tooltip).toContainText("Summary")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
|
||||
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/Projects/settings-demo"
|
||||
@@ -248,41 +248,6 @@ test("project settings open as a nested view that keeps its route", async ({ pag
|
||||
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// The desktop app reopens its last settings URL on launch, so a crash on this page would repeat on every restart.
|
||||
test("a project whose folder is gone opens its settings with a notice", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Settings demo",
|
||||
directory,
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
seed: { tabs: [] },
|
||||
})
|
||||
await mockMissingLocation(page, directory)
|
||||
|
||||
const plugins = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
|
||||
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
|
||||
})
|
||||
|
||||
await page.goto(
|
||||
`/settings?server=${encodeURIComponent(SERVER)}&project=${encodeURIComponent(directory)}&tab=extensions`,
|
||||
)
|
||||
expect((await plugins).status()).toBe(404)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const notice = settings.getByRole("status").filter({ hasText: "Project folder not found" })
|
||||
await expect(notice).toContainText(directory)
|
||||
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
|
||||
await expect(
|
||||
settings.getByRole("tabpanel", { name: "Plugins", exact: true }).getByText("No plugins yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "General", exact: true }).click()
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toBeVisible()
|
||||
await expect(notice).toContainText(directory)
|
||||
})
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
@@ -785,464 +750,6 @@ for (const row of [
|
||||
})
|
||||
}
|
||||
|
||||
const consoleIntegrations = [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [
|
||||
{ id: "device", type: "oauth", label: "OpenCode Console account" },
|
||||
{ type: "key", label: "API key (service account)" },
|
||||
],
|
||||
connections: [],
|
||||
},
|
||||
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
|
||||
]
|
||||
|
||||
for (const row of [
|
||||
{ provider: "OpenCode Console", connect: "Connect OpenCode Console", signIn: "Connecting to OpenCode Console" },
|
||||
{ provider: "OpenCode Go", connect: "Connect OpenCode Go", signIn: "Connecting to OpenCode Go" },
|
||||
]) {
|
||||
test(`providers: ${row.provider} signs in from the Console intro`, async ({ page, context }) => {
|
||||
const url = "https://auth.example.test/device?user_code=ABCD-EFGH"
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
const { settings } = await open(page, { integrations: consoleIntegrations, onIntegrationOAuth: () => ({ url }) })
|
||||
|
||||
// Registered after the mock server so it sees the sign-in request first.
|
||||
const start = await holdRoute(page, (url) => url.pathname === "/api/integration/opencode/connect/oauth", {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings
|
||||
.locator(".settings-provider-row", { hasText: row.provider })
|
||||
.getByRole("button", { name: "Connect", exact: true })
|
||||
.click()
|
||||
const intro = page.getByRole("dialog", { name: row.connect, exact: true })
|
||||
const container = page.locator('[data-slot="dialog-container"]')
|
||||
await expect(
|
||||
intro.getByText("Sign in with your OpenCode Console account to use the available models."),
|
||||
).toBeVisible()
|
||||
await expect(intro.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
|
||||
expect((await container.boundingBox())?.height).toBe(512)
|
||||
|
||||
const popup = page.waitForEvent("popup")
|
||||
await intro.getByRole("button", { name: "Continue in the browser", exact: true }).click()
|
||||
await start.arrived
|
||||
await expect(intro.getByRole("button", { name: "Opening browser…", exact: true })).toBeDisabled()
|
||||
expect((await container.boundingBox())?.height).toBe(512)
|
||||
start.release()
|
||||
await expect(await popup).toHaveURL(url)
|
||||
|
||||
const signIn = page.getByRole("dialog", { name: row.signIn, exact: true })
|
||||
await expect(signIn.getByRole("group", { name: "Device code: ABCD-EFGH", exact: true })).toBeVisible()
|
||||
await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible()
|
||||
await expect(signIn.getByRole("button", { name: "Use API key", exact: true })).toHaveCount(0)
|
||||
await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512)
|
||||
})
|
||||
}
|
||||
|
||||
test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "key" },
|
||||
]
|
||||
|
||||
const state = { active: "cred_work" }
|
||||
const activated: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
integrations: () => [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
activated.push(credentialID)
|
||||
state.active = credentialID
|
||||
},
|
||||
onCredentialUpdate: (credentialID, body) => {
|
||||
const account = accounts.find((item) => item.id === credentialID)
|
||||
|
||||
if (account && body.label) account.label = body.label
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
const openai = dialog.locator('[data-provider-id="openai"]')
|
||||
const count = openai.locator('[data-component="tag"]')
|
||||
await expect(count).toHaveText("2")
|
||||
await expect(count).toHaveAttribute("aria-label", "2 accounts")
|
||||
await expect(dialog.locator('[data-provider-id="anthropic"]').locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await openai.click()
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
|
||||
const list = dialog.getByRole("radiogroup", { name: "Connected accounts", exact: true })
|
||||
await expect(list.getByRole("radio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
// Rows keep their order when the active account changes.
|
||||
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
|
||||
await list.getByRole("radio", { name: "Personal", exact: true }).click()
|
||||
await expect(list.getByRole("radio", { name: "Personal", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
|
||||
expect(activated).toEqual(["cred_personal"])
|
||||
|
||||
await list.getByRole("button", { name: "Personal options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const name = list.getByRole("textbox", { name: "Account name", exact: true })
|
||||
await expect(name).toBeFocused()
|
||||
await expect(name).toHaveValue("Personal")
|
||||
await name.fill("Home")
|
||||
await name.press("Enter")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
|
||||
await expect(list.getByRole("radio", { name: "Home", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await list.getByRole("button", { name: "Home options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Ignored")
|
||||
await name.press("Escape")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
|
||||
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
|
||||
|
||||
await list.getByRole("button", { name: "Work options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
|
||||
await expect(dialog.getByText("Remove Work?", { exact: true })).toBeVisible()
|
||||
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/credential/cred_work",
|
||||
)
|
||||
|
||||
await dialog.getByRole("button", { name: "Remove", exact: true }).click()
|
||||
await removed
|
||||
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await expect(list).toBeVisible()
|
||||
})
|
||||
|
||||
test("providers: adding a Go account uses a Go key, and a lone sign-in starts once", async ({ page, context }) => {
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
const attempts: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
integrations: [
|
||||
...consoleIntegrations.filter((integration) => integration.id !== "opencode-go"),
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_go", label: "Go key", method: "key" }],
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
methods: [{ id: "device", type: "oauth", label: "GitHub" }],
|
||||
connections: [{ type: "credential", id: "cred_gh", label: "Octocat", method: "oauth" }],
|
||||
},
|
||||
],
|
||||
onIntegrationOAuth: (input) => {
|
||||
attempts.push(input.integrationID)
|
||||
|
||||
return { url: "https://auth.example.test/device?user_code=GH12-3456" }
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.locator('[data-provider-id="opencode-go"]').click()
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
|
||||
expect(attempts).toEqual([])
|
||||
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await expect(dialog.getByRole("radio", { name: "Go key", exact: true })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await dialog.locator('[data-provider-id="github-copilot"]').click()
|
||||
const popup = page.waitForEvent("popup")
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await popup
|
||||
await expect.poll(() => attempts).toEqual(["github-copilot"])
|
||||
expect(attempts).toEqual(["github-copilot"])
|
||||
})
|
||||
|
||||
test("providers: the account options rename and remove the active account", async ({ page }) => {
|
||||
const account = { type: "credential", id: "cred_work", label: "Work", method: "key" }
|
||||
const other = { type: "credential", id: "cred_personal", label: "Personal", method: "key" }
|
||||
const removed: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: { all: [{ id: "openai", name: "OpenAI", models: {} }], connected: ["openai"], default: {} },
|
||||
integrations: () => [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: [account, other].filter((item) => !removed.includes(item.id)),
|
||||
},
|
||||
],
|
||||
onCredentialUpdate: (credentialID, body) => {
|
||||
const target = [account, other].find((item) => item.id === credentialID)
|
||||
|
||||
if (target && body.label) target.label = body.label
|
||||
},
|
||||
})
|
||||
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
|
||||
if (request.method() === "DELETE" && path.startsWith("/api/credential/")) removed.push(path.split("/").at(-1)!)
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const trigger = settings.getByRole("button", { name: "Manage OpenAI accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Work")
|
||||
|
||||
await trigger.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
await expect(page.getByRole("menuitem", { name: "Add account", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const options = settings.getByRole("button", { name: "Work options", exact: true })
|
||||
await expect(options).toHaveCSS("height", "28px")
|
||||
await options.click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const name = settings.getByRole("textbox", { name: "Account name", exact: true })
|
||||
await expect(name).toBeFocused()
|
||||
await expect(name).toHaveValue("Work")
|
||||
await name.fill("Team")
|
||||
await name.press("Enter")
|
||||
await expect(trigger).toHaveText("Team")
|
||||
expect(other.label).toBe("Personal")
|
||||
|
||||
await settings.getByRole("button", { name: "Team options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Ignored")
|
||||
await name.press("Escape")
|
||||
await expect(trigger).toHaveText("Team")
|
||||
|
||||
await settings.getByRole("button", { name: "Team options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Shared")
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await expect(trigger).toHaveText("Shared")
|
||||
|
||||
await settings.getByRole("button", { name: "Shared options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
|
||||
await expect.poll(() => removed).toEqual(["cred_work"])
|
||||
})
|
||||
|
||||
test("providers: switching to a Zen API key does not wait for a Console workspace", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" },
|
||||
{ type: "credential", id: "cred_zen", label: "Zen key", method: "key" },
|
||||
]
|
||||
|
||||
const state = { active: "cred_console" }
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: () =>
|
||||
state.active === "cred_console"
|
||||
? {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Zen",
|
||||
models: { paid: { id: "paid", name: "Paid", cost: { input: 3 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: {},
|
||||
},
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
state.active = credentialID
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const trigger = settings.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Anomaly")
|
||||
await trigger.click()
|
||||
|
||||
const catalogReads: string[] = []
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "GET" && new URL(request.url()).pathname === "/api/provider")
|
||||
catalogReads.push(request.url())
|
||||
})
|
||||
await page.getByRole("menuitemradio", { name: "Zen key", exact: true }).click()
|
||||
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
|
||||
// One refresh after the switch; a Console workspace poll would keep reading the catalog.
|
||||
expect(catalogReads).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("providers: connecting OpenCode Go keeps the Console row in place", async ({ page, context }) => {
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
|
||||
// After the sign-in the server reloads the workspace, briefly serving only the free catalog.
|
||||
const state = { authorized: false, pendingReads: 0 }
|
||||
|
||||
const workspace = {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
|
||||
const catalog = () => {
|
||||
if (state.pendingReads > 0) {
|
||||
state.pendingReads--
|
||||
|
||||
return { all: [{ id: "opencode", name: "OpenCode", models: {} }], connected: ["opencode"], default: {} }
|
||||
}
|
||||
|
||||
if (!state.authorized) return workspace
|
||||
|
||||
return {
|
||||
all: [...workspace.all, { id: "opencode-go", integrationID: "opencode-go", name: "OpenCode Go", models: {} }],
|
||||
connected: [...workspace.connected, "opencode-go"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: catalog,
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }],
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
methods: [{ type: "key" }],
|
||||
connections: state.authorized ? [{ type: "credential", id: "cred_go", label: "Anomaly", method: "oauth" }] : [],
|
||||
},
|
||||
],
|
||||
onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }),
|
||||
onIntegrationOAuthStatus: () => {
|
||||
if (!state.authorized) {
|
||||
state.authorized = true
|
||||
state.pendingReads = 3
|
||||
}
|
||||
|
||||
return "complete"
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
await connected.evaluate((element) => {
|
||||
new MutationObserver(() => {
|
||||
if (!element.textContent?.includes("OpenCode Console")) element.dataset.flashed = ""
|
||||
}).observe(element, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
|
||||
await settings
|
||||
.locator(".settings-provider-row", { hasText: "OpenCode Go" })
|
||||
.getByRole("button", { name: "Connect", exact: true })
|
||||
.click()
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.getByRole("button", { name: "Continue in the browser", exact: true }).click()
|
||||
await popup
|
||||
await expect(page.getByText("OpenCode Go connected", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
|
||||
await expect(connected.getByText("OpenCode Go", { exact: true })).toBeVisible()
|
||||
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
await expect(connected).not.toHaveAttribute("data-flashed")
|
||||
})
|
||||
|
||||
test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_clara", label: "Clara", method: "oauth" },
|
||||
{ type: "credential", id: "cred_anomaly", label: "Anomaly", method: "oauth" },
|
||||
]
|
||||
|
||||
// The server loads the new workspace's providers a few catalog reads after the switch.
|
||||
const state = { active: "cred_clara", pendingReads: 0 }
|
||||
const workspace = () => accounts.find((account) => account.id === state.active)!.label
|
||||
|
||||
const catalog = () => {
|
||||
if (state.pendingReads > 0) {
|
||||
state.pendingReads--
|
||||
|
||||
return {
|
||||
all: [{ id: "opencode", name: "OpenCode", models: { free: { id: "free", name: "Free", cost: { input: 0 } } } }],
|
||||
connected: ["opencode"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: `${workspace()} / OpenCode`, models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: `${workspace()} / Google`, models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: catalog,
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
state.active = credentialID
|
||||
state.pendingReads = 2
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
const trigger = connected.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Clara")
|
||||
await connected.evaluate((element) => {
|
||||
new MutationObserver(() => {
|
||||
if (element.textContent?.includes("No connected providers")) element.dataset.flashed = ""
|
||||
}).observe(element, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitemradio", { name: "Anomaly", exact: true }).click()
|
||||
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
|
||||
await expect(trigger).toHaveText("Anomaly")
|
||||
await expect(connected).not.toHaveAttribute("data-flashed")
|
||||
})
|
||||
|
||||
test("the add server dialog keeps focus above fullscreen settings", async ({ page }) => {
|
||||
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
|
||||
const { settings } = await open(page)
|
||||
|
||||
@@ -109,7 +109,7 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
),
|
||||
]
|
||||
|
||||
const { session } = await openSession(page, { name: "SideStack", pty: {}, vcsDiff: diffs })
|
||||
await openSession(page, { name: "SideStack", pty: {}, vcsDiff: diffs })
|
||||
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||
const reviewPanel = page.locator("#review-panel")
|
||||
const terminalPanel = page.locator("#terminal-panel")
|
||||
@@ -163,30 +163,13 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
expect(Math.abs(closing.terminalBottom - stacked.terminalBottom)).toBeLessThanOrEqual(1)
|
||||
expect(closing.anchor).toBeLessThanOrEqual(8)
|
||||
expect(closing.terminalGap).toBeLessThanOrEqual(1)
|
||||
expect(closing.reviewOverlap).toBeLessThanOrEqual(1)
|
||||
|
||||
await toggle.click()
|
||||
await expect(reviewPanel).toBeVisible()
|
||||
await expectMarked(review)
|
||||
await expectTreeSentinel(page)
|
||||
|
||||
// Panels restored by a reload skip their entrance animation, and must still move together when the terminal hides.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")[key],
|
||||
tabKey(session.id),
|
||||
),
|
||||
)
|
||||
.toMatchObject({ review: true, terminal: true })
|
||||
await page.reload()
|
||||
await expect(reviewPanel).toBeVisible()
|
||||
await expect(terminal).toBeVisible()
|
||||
await expectTreeSentinel(page)
|
||||
await mark(terminal)
|
||||
|
||||
// Hiding the terminal mid-motion: both surfaces fill their regions, the review never runs under the gap, and the
|
||||
// clipped terminal keeps its size.
|
||||
// Hiding the terminal mid-motion: both surfaces fill their regions and the clipped terminal keeps its size.
|
||||
const open = await stackGeometry(page)
|
||||
await holdTransitions(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
@@ -197,7 +180,6 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
expect(hiding.terminalRegion).toBeLessThan(open.terminalRegion - 1)
|
||||
expect(hiding.terminalRegion).toBeGreaterThan(1)
|
||||
expect(hiding.reviewGap).toBeLessThanOrEqual(1)
|
||||
expect(hiding.reviewOverlap).toBeLessThanOrEqual(1)
|
||||
expect(hiding.terminalGap).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(hiding.content.width - open.content.width)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(hiding.content.height - open.content.height)).toBeLessThanOrEqual(1)
|
||||
@@ -311,23 +293,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
return actions.y + actions.height / 2 - (button.y + button.height / 2)
|
||||
})
|
||||
.toBe(0)
|
||||
|
||||
// Toggling the terminal under the review leaves the review header and the toggle in place.
|
||||
const header = async () => {
|
||||
const box = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
|
||||
return box && { y: box.y, height: box.height }
|
||||
}
|
||||
|
||||
const stacked = await header()
|
||||
|
||||
for (const visible of [false, true]) {
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible({ visible })
|
||||
await expect.poll(header).toEqual(stacked)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
}
|
||||
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
@@ -356,7 +321,6 @@ async function expectTreeSentinel(page: Page) {
|
||||
// Pauses each CSS transition as it starts, so a real toggle can be measured mid-motion.
|
||||
function holdTransitions(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
// SAFETY: only these two helpers write this optional global, in the declared shape.
|
||||
const host = window as Window & { e2eHold?: { active: boolean } }
|
||||
|
||||
if (!host.e2eHold) {
|
||||
@@ -377,9 +341,7 @@ function holdTransitions(page: Page) {
|
||||
|
||||
function releaseTransitions(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
// SAFETY: `holdTransitions` installed this global, in this shape, before any release.
|
||||
const host = window as Window & { e2eHold?: { active: boolean } }
|
||||
host.e2eHold!.active = false
|
||||
;(window as Window & { e2eHold?: { active: boolean } }).e2eHold!.active = false
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((item) => item instanceof CSSTransition && item.playState === "paused")
|
||||
@@ -397,7 +359,7 @@ async function stackGeometry(page: Page, progress?: number) {
|
||||
(item) =>
|
||||
item instanceof CSSTransition && item.playState === "paused" && item.transitionProperty === "height",
|
||||
)
|
||||
.map((item) => (item.effect instanceof KeyframeEffect ? item.effect.target?.getAttribute("data-slot") : null)),
|
||||
.map((item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot")),
|
||||
)
|
||||
|
||||
if (progress !== undefined) {
|
||||
@@ -409,10 +371,9 @@ async function stackGeometry(page: Page, progress?: number) {
|
||||
.getAnimations()
|
||||
.filter((item): item is CSSTransition => item instanceof CSSTransition && item.playState === "paused")
|
||||
|
||||
const slot = (item: CSSTransition) =>
|
||||
item.effect instanceof KeyframeEffect ? item.effect.target?.getAttribute("data-slot") : null
|
||||
|
||||
const region = transitions.find((item) => slot(item) === "session-side-region")
|
||||
const region = transitions.find(
|
||||
(item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot") === "session-side-region",
|
||||
)
|
||||
|
||||
if (progress !== undefined) {
|
||||
const time = Number(region!.effect!.getTiming().duration) * progress
|
||||
@@ -421,20 +382,17 @@ async function stackGeometry(page: Page, progress?: number) {
|
||||
|
||||
const box = (selector: string) => document.querySelector(selector)?.getBoundingClientRect()
|
||||
const reviewRegion = box('[data-slot="session-side-region"]')!
|
||||
const gap = box('[data-slot="session-side-panel-gap"]')!
|
||||
const terminalRegion = box('[data-slot="session-side-terminal-region"]')!
|
||||
const review = box("#review-panel")
|
||||
const terminal = box("#terminal-panel")!
|
||||
const content = box('[data-slot="terminal-panel-content"]')!
|
||||
|
||||
return {
|
||||
moving: transitions.map(slot),
|
||||
moving: transitions.map((item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot")),
|
||||
terminalRegion: terminalRegion.height,
|
||||
terminalBottom: terminal.bottom,
|
||||
anchor: Math.abs(terminal.top - content.top),
|
||||
reviewGap: review ? Math.abs(reviewRegion.height - review.height) : 0,
|
||||
// The gap paints over the review region, so any overlap hides the review's bottom corners.
|
||||
reviewOverlap: reviewRegion.bottom - gap.top,
|
||||
terminalGap: Math.abs(terminalRegion.height - terminal.height),
|
||||
content: { width: content.width, height: content.height },
|
||||
}
|
||||
@@ -455,8 +413,8 @@ async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator)
|
||||
.poll(async () => {
|
||||
const centers = await Promise.all(
|
||||
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
|
||||
button.locator("svg").evaluate((svg) => {
|
||||
if (!(svg instanceof SVGGraphicsElement)) return Number.NaN
|
||||
button.locator("svg").evaluate((element) => {
|
||||
const svg = element as SVGSVGElement
|
||||
const path = svg.getBBox()
|
||||
|
||||
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
|
||||
|
||||
@@ -57,13 +57,9 @@ test("keeps the parent title anchored when opening a subagent", async ({ page })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
// The title first renders in the loading header, which the timeline replaces once messages arrive.
|
||||
// Measuring before then can read the detached heading, whose empty bounds put the start at 0.
|
||||
const agents = page.getByRole("button", { name: "Used 1 Agent", exact: true })
|
||||
await expect(agents).toBeVisible()
|
||||
const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))
|
||||
|
||||
await agents.click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
|
||||
@@ -56,71 +56,6 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
})
|
||||
|
||||
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(a.id))
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
const dot = tabB.locator('[data-slot="tab-unread-dot"]')
|
||||
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
|
||||
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
|
||||
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(dot).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "0")
|
||||
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
dot.evaluate((element) => {
|
||||
const dot = element.getBoundingClientRect()
|
||||
const slot = element.parentElement!.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
|
||||
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(1)
|
||||
|
||||
await tabB.hover()
|
||||
await expect(dot).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "1")
|
||||
await page.mouse.move(0, 0)
|
||||
await tabB.locator("[data-titlebar-tab-link]").focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(close).toBeFocused()
|
||||
await expect(dot).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "1")
|
||||
await close.blur()
|
||||
await expect(dot).toBeVisible()
|
||||
|
||||
await tabB.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(tabB).toHaveAttribute("data-active", "true")
|
||||
await expect(dot).toHaveCount(0)
|
||||
await tabA.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(dot).toBeVisible()
|
||||
await tabB.hover()
|
||||
await close.click()
|
||||
await expect(tabB).toHaveCount(0)
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
})
|
||||
|
||||
test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
|
||||
await page.goto(sessionHref(a.id))
|
||||
@@ -162,139 +97,44 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[c.id]: [
|
||||
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
|
||||
).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
|
||||
await page.keyboard.press("Alt+ArrowUp")
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
|
||||
await page.keyboard.press("Shift+Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
|
||||
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
|
||||
{ tabLayout: "horizontal", width: 360 },
|
||||
{ tabLayout: "vertical", width: 390 },
|
||||
]) {
|
||||
test.describe(`mobile drawer (${row.tabLayout})`, () => {
|
||||
test.use({ hasTouch: row.hasTouch })
|
||||
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: row.width, height: 720 })
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{
|
||||
id: "permission-mobile-b",
|
||||
sessionID: b.id,
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-unread-dot"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("width", "20px")
|
||||
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("height", "20px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
tabB.evaluate((element) => {
|
||||
const dot = element.querySelector('[data-slot="tab-unread-slot"]')!.getBoundingClientRect()
|
||||
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
|
||||
|
||||
return dot.right <= close.left || dot.left >= close.right
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
|
||||
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
|
||||
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
|
||||
if (row.tabLayout !== "vertical") return
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: row.width, height: 720 })
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
|
||||
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
|
||||
if (row.tabLayout !== "vertical") return
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "CompactTabs",
|
||||
sessions: [
|
||||
a,
|
||||
b,
|
||||
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
|
||||
],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{
|
||||
id: "permission-compact-b",
|
||||
sessionID: b.id,
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await tabB.hover()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await tabB.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 480 })
|
||||
const directory = "C:/OpenCode/Tabs"
|
||||
@@ -362,145 +202,6 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
})
|
||||
|
||||
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const rows = sidebar.locator("[data-titlebar-tab]")
|
||||
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
|
||||
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title])
|
||||
const upper = await tabB.boundingBox()
|
||||
const lower = await tabC.boundingBox()
|
||||
|
||||
if (!upper || !lower) throw new Error("vertical tabs have no bounding boxes")
|
||||
expect(lower.y - upper.y - upper.height).toBe(4)
|
||||
|
||||
const middle = upper.y + upper.height + 2
|
||||
|
||||
// The two halves belong to their nearest row; an exact tie belongs to the next row.
|
||||
for (const [offset, id] of [
|
||||
[-1, b.id],
|
||||
[0, c.id],
|
||||
] as const) {
|
||||
await page.mouse.move(upper.x + upper.width / 2, middle + offset)
|
||||
await expect(sidebar.locator("[data-titlebar-tab]:hover [data-titlebar-tab-link]")).toHaveAttribute(
|
||||
"href",
|
||||
sessionHref(id),
|
||||
)
|
||||
}
|
||||
|
||||
await page.mouse.click(upper.x + upper.width / 2, middle + 1)
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
|
||||
await page.mouse.move(upper.x + upper.width / 2, middle - 1)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(upper.x + upper.width / 2, lower.y + lower.height - 2, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title, b.title])
|
||||
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title])
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
|
||||
const list = await sidebar.locator("[data-titlebar-tab-list]").boundingBox()
|
||||
|
||||
if (!list) throw new Error("vertical tab list has no bounding box")
|
||||
|
||||
for (const [x, y] of [
|
||||
[list.x + list.width / 2, list.y - 1],
|
||||
[list.x + list.width / 2, list.y + list.height + 1],
|
||||
[list.x - 1, list.y + list.height / 2],
|
||||
[list.x + list.width + 1, list.y + list.height / 2],
|
||||
]) {
|
||||
await page.mouse.move(x, y)
|
||||
await expect(sidebar.locator("[data-titlebar-tab]:hover")).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("tab preview opens cold, switches immediately between tabs, and closes outside the strip", async ({ page }) => {
|
||||
const d = { id: "ses_tab_d", title: "Library" }
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c, d],
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const rows = sidebar.locator("[data-titlebar-tab]")
|
||||
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
|
||||
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
|
||||
const preview = page.locator('[data-component="session-tab-popover"]')
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
|
||||
// Until it is paused the installed clock follows real time, and it starts slightly ahead of the runner's wall clock,
|
||||
// so `pauseAt(new Date())` can name an instant the page has already passed. Pause at a fixed offset from an explicit
|
||||
// install time instead; the offset only has to outlast the real time between the two calls.
|
||||
const start = Date.now()
|
||||
await page.clock.install({ time: start })
|
||||
await page.clock.pauseAt(start + 10_000)
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(700)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.clock.runFor(50)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await page.clock.runFor(10_000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
|
||||
const box = await tabB.boundingBox()
|
||||
|
||||
if (!box) throw new Error("vertical tab has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 1)
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).hover()
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabC.hover({ position: { x: box.width - 1, y: box.height / 2 } })
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(1)
|
||||
await expect(preview).toContainText(c.title)
|
||||
|
||||
await expect(preview).toHaveCSS("pointer-events", "none")
|
||||
await rows.filter({ has: page.locator(`a[href="${sessionHref(d.id)}"]`) }).hover()
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(1)
|
||||
await expect(preview).toContainText(d.title)
|
||||
const card = await preview.boundingBox()
|
||||
|
||||
if (!card) throw new Error("tab preview has no bounding box")
|
||||
// The card is informational: entering its painted area leaves the tab strip.
|
||||
await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2)
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(0)
|
||||
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(800)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 20, { steps: 8 })
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
// Removing an open tab must not leave the shared preview state permanently warm.
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(800)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await page.mouse.move(900, 500)
|
||||
await page.clock.runFor(600)
|
||||
await tabC.hover()
|
||||
await page.clock.runFor(700)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.clock.runFor(50)
|
||||
await expect(preview).toContainText(c.title)
|
||||
})
|
||||
|
||||
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
||||
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
|
||||
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
|
||||
@@ -832,70 +533,6 @@ test("each session tab shows its own file tab after a switch to a session in ano
|
||||
}
|
||||
})
|
||||
|
||||
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/SidePanelTabs"
|
||||
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
|
||||
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
|
||||
await openSession(page, {
|
||||
name: "SidePanelTabs",
|
||||
sessions: [alpha, beta],
|
||||
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } },
|
||||
})
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
|
||||
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await panel.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
|
||||
await composer.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
|
||||
await panel.getByText("contents:README.md", { exact: true }).click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Shift+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(fileTab).toHaveCount(0)
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await reviewTab.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
})
|
||||
|
||||
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
|
||||
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
|
||||
|
||||
@@ -30,7 +30,7 @@ for (const width of [1440, 390]) {
|
||||
|
||||
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
|
||||
|
||||
window.__coldReveal = {
|
||||
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
|
||||
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
|
||||
@@ -55,7 +55,7 @@ for (const width of [1440, 390]) {
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
|
||||
release.resolve()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const reveal = await page.evaluate(() => window.__coldReveal)
|
||||
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
|
||||
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
|
||||
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
// The gap above the composer is part of the tail.
|
||||
@@ -274,23 +274,7 @@ test("loads the transcript code font before opening rich history", async ({ page
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
|
||||
expect((await font).ok()).toBe(true)
|
||||
// Chromium recreates every @font-face as "unloaded", keeping its downloaded file, whenever a lazily loaded
|
||||
// stylesheet with @layer rules activates ahead of one already applied, so the preloaded face's status alone is
|
||||
// not stable. Once fonts settle, the first layout of new code must take that file at once, which marks the
|
||||
// current face loaded; a face that still has to fetch its file reports "loading" here instead.
|
||||
expect(
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready
|
||||
const code = document.body.appendChild(document.createElement("code"))
|
||||
code.style.font = "440 13px var(--font-family-mono)"
|
||||
code.textContent = "code"
|
||||
code.getBoundingClientRect()
|
||||
const loaded = document.fonts.check('440 13px "IBM Plex Mono"')
|
||||
code.remove()
|
||||
|
||||
return loaded
|
||||
}),
|
||||
).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
|
||||
})
|
||||
|
||||
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
|
||||
@@ -480,13 +464,6 @@ async function expectAtTail(page: Page, lastUserID: string) {
|
||||
|
||||
type TabPaint = { first?: { last: boolean; bottomError?: number }; settled: boolean; removed: number; stop(): void }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__coldReveal?: Reveal
|
||||
__tabPaint?: TabPaint
|
||||
}
|
||||
}
|
||||
|
||||
// Samples painted frames of the timeline showing `sessionID`: the first frame that shows any of its messages, and
|
||||
// every node of the first settled frame (at the tail, Markdown rendered) that is later removed.
|
||||
async function sampleTabPaint(page: Page, sessionID: string) {
|
||||
@@ -505,7 +482,7 @@ async function sampleTabPaint(page: Page, sessionID: string) {
|
||||
},
|
||||
}
|
||||
|
||||
window.__tabPaint = state
|
||||
;(window as Window & { __tabPaint?: TabPaint }).__tabPaint = state
|
||||
new MutationObserver((records) => {
|
||||
if (!state.settled || !running) return
|
||||
records.forEach((record) =>
|
||||
@@ -543,11 +520,10 @@ async function sampleTabPaint(page: Page, sessionID: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
|
||||
const id = element.dataset.messageId
|
||||
|
||||
return id && inView(element) && destination.has(id) ? [id] : []
|
||||
})
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter(inView)
|
||||
.map((element) => element.dataset.messageId!)
|
||||
.filter((id) => destination.has(id))
|
||||
|
||||
const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
const bottomError = spacer ? spacer.bottom - view.bottom : undefined
|
||||
@@ -580,20 +556,20 @@ async function sampleTabPaint(page: Page, sessionID: string) {
|
||||
last: history.findLast((message) => message.type === "user")!.id,
|
||||
},
|
||||
)
|
||||
const read = () => page.evaluate(() => window.__tabPaint!.first)
|
||||
const read = () => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.first)
|
||||
|
||||
return {
|
||||
async firstPaint() {
|
||||
await expect.poll(read).toBeDefined()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__tabPaint!.settled))
|
||||
.poll(() => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.settled))
|
||||
.toBe(true)
|
||||
|
||||
return (await read())!
|
||||
},
|
||||
stop: () =>
|
||||
page.evaluate(() => {
|
||||
const state = window.__tabPaint!
|
||||
const state = (window as Window & { __tabPaint?: TabPaint }).__tabPaint!
|
||||
state.stop()
|
||||
|
||||
return state.removed
|
||||
|
||||
@@ -68,7 +68,8 @@ const upgraded = { storage: { "highlights.v1": { version: "1.9.1" } } }
|
||||
test("What's New shows the highlights since the version seen, pages to Get started, and is seen once shown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = await startUpgraded(page)
|
||||
const requests = await serveChangelog(page)
|
||||
await start(page, upgraded)
|
||||
const dialog = page.getByRole("dialog")
|
||||
const title = dialog.getByRole("heading", { level: 1 })
|
||||
const next = dialog.getByRole("button", { name: "Next", exact: true })
|
||||
@@ -101,7 +102,8 @@ test("What's New shows the highlights since the version seen, pages to Get start
|
||||
})
|
||||
|
||||
test("Don't show these in the future turns release notes off and closes What's New", async ({ page }) => {
|
||||
await startUpgraded(page)
|
||||
await serveChangelog(page)
|
||||
await start(page, upgraded)
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
|
||||
await dialog.getByRole("button", { name: "Don't show these in the future", exact: true }).click()
|
||||
@@ -220,19 +222,6 @@ async function start(page: Page, seed: SeedInput) {
|
||||
await expectAppVisible(page.getByRole("button", { name: "Settings", exact: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts as a user who last saw 1.9.1 and serves the changelog only once Home renders. What's New is a modal that hides
|
||||
* Home from assistive technology, so opening it first would hide the Settings button `start` waits for.
|
||||
*/
|
||||
async function startUpgraded(page: Page) {
|
||||
const held = Promise.withResolvers<void>()
|
||||
const requests = await serveChangelog(page, changelog, held.promise)
|
||||
await start(page, upgraded)
|
||||
held.resolve()
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
function stored(page: Page, key: string) {
|
||||
return page.evaluate((name) => localStorage.getItem(name), key).then((raw) => JSON.parse(raw ?? "null"))
|
||||
}
|
||||
@@ -125,19 +125,6 @@ const Group = HttpApiGroup.make("mock")
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("credentialActivate", "/api/credential/:credentialID/activate", {
|
||||
params: { credentialID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("credentialUpdate", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
|
||||
@@ -44,11 +44,7 @@ export type MockFileContent =
|
||||
export interface MockServerConfig {
|
||||
server?: string
|
||||
provider: Resolvable<MockProviderCatalog>
|
||||
integrations?: Resolvable<unknown[]>
|
||||
// Runs on POST /api/credential/:id/activate; the fixture swaps its integrations and catalog to the new account.
|
||||
onCredentialActivate?: (credentialID: string) => void
|
||||
// Runs on PATCH /api/credential/:id with the request body; the fixture applies the new label.
|
||||
onCredentialUpdate?: (credentialID: string, body: { label?: string }) => void
|
||||
integrations?: unknown[]
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
// Terminal shells the settings offer (`/api/config/shell`).
|
||||
shells?: unknown[]
|
||||
@@ -107,8 +103,6 @@ export interface MockServerConfig {
|
||||
// Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending.
|
||||
// Without it, OAuth connects answer 501 MockUnsupported.
|
||||
onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string }
|
||||
// Answers status polls for an OAuth attempt; without it every attempt stays pending.
|
||||
onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete"
|
||||
plugins?: Resolvable<unknown[]>
|
||||
skills?: Resolvable<unknown[]>
|
||||
// Replaces the `/api/worktree` inventory, which defaults to the directory plus project sandboxes.
|
||||
@@ -476,21 +470,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return { server, pty: transport.pty, push }
|
||||
}
|
||||
|
||||
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
|
||||
export function mockMissingLocation(page: Page, directory: string) {
|
||||
return page.route(
|
||||
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
|
||||
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
|
||||
return Object.fromEntries(
|
||||
@@ -870,20 +849,19 @@ function mockHandlers(
|
||||
model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }),
|
||||
modelDefault: () =>
|
||||
Effect.succeed({ location: location(config), data: currentDefaultModel(resolve(config.provider)) }),
|
||||
integrationList: () =>
|
||||
Effect.sync(() => ({ location: location(config), data: resolve(config.integrations ?? []) })),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }),
|
||||
integrationGet: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: resolve(config.integrations ?? [])
|
||||
.filter(Predicate.isObject)
|
||||
data: config.integrations
|
||||
?.filter(Predicate.isObject)
|
||||
.find((integration) => integration.id === ctx.params.integrationID) ?? {
|
||||
id: ctx.params.integrationID,
|
||||
name: ctx.params.integrationID,
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
})),
|
||||
}),
|
||||
integrationConnect: (ctx) =>
|
||||
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
|
||||
Effect.andThen(noContent),
|
||||
@@ -920,18 +898,9 @@ function mockHandlers(
|
||||
|
||||
return Effect.succeed({
|
||||
location: location(config, requestDirectory(config, ctx.request)),
|
||||
data: {
|
||||
status: config.onIntegrationOAuthStatus?.(ctx.params.attemptID) ?? "pending",
|
||||
time: { created, expires: created + 600_000 },
|
||||
},
|
||||
data: { status: "pending", time: { created, expires: created + 600_000 } },
|
||||
})
|
||||
}),
|
||||
credentialActivate: (ctx) =>
|
||||
Effect.sync(() => config.onCredentialActivate?.(ctx.params.credentialID)).pipe(Effect.andThen(noContent)),
|
||||
credentialUpdate: (ctx) =>
|
||||
Effect.sync(() => config.onCredentialUpdate?.(ctx.params.credentialID, ctx.payload)).pipe(
|
||||
Effect.andThen(noContent),
|
||||
),
|
||||
credentialRemove: () => noContent,
|
||||
command: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
@@ -1435,8 +1404,6 @@ function currentProviders(catalog: MockProviderCatalog) {
|
||||
.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
canonical: provider.canonical,
|
||||
integrationID: provider.integrationID,
|
||||
package: provider.id,
|
||||
activation: connected.has(provider.id) ? "enabled" : "auto",
|
||||
}))
|
||||
@@ -1510,7 +1477,7 @@ export function currentSession(session: MockSession, fallbackDirectory?: string)
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
agent: session.agent ?? "build",
|
||||
model: session.model,
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...decodeSessionOutcome(session),
|
||||
@@ -1568,14 +1535,7 @@ const decodePromptPayload = Schema.decodeUnknownOption(
|
||||
}),
|
||||
)
|
||||
|
||||
const decodeProvider = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
canonical: lenient(Schema.String),
|
||||
integrationID: lenient(Schema.String),
|
||||
}),
|
||||
)
|
||||
const decodeProvider = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.String, name: Schema.String }))
|
||||
|
||||
const decodeModelProvider = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ id: Schema.String, models: Schema.Record(Schema.String, Schema.Unknown) }),
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
// Serves the e2e build with vite preview's routing rules. vite preview checks the case of every requested
|
||||
// path with a synchronous directory read on case-insensitive file systems (Windows, macOS), and the
|
||||
// 3000-file _assets directory makes that the bottleneck for every page load across all workers.
|
||||
const options = parseArgs({ options: { host: { type: "string" }, port: { type: "string" } } }).values
|
||||
|
||||
const dist = path.resolve(import.meta.dirname, "../../dist")
|
||||
|
||||
// Sent as text like vite's index.html middleware, so a media Range request that falls back gets a plain 200.
|
||||
const index = await Bun.file(path.join(dist, "index.html")).text()
|
||||
|
||||
Bun.serve({
|
||||
hostname: options.host,
|
||||
port: Number(options.port),
|
||||
async fetch(request) {
|
||||
const pathname = decode(new URL(request.url).pathname)
|
||||
|
||||
if (pathname === undefined) return new Response(null, { status: 404 })
|
||||
|
||||
const target = path.resolve(dist, `.${pathname}`)
|
||||
|
||||
// Like vite's existsSync, a name the file system rejects (too long, NUL) is treated as missing.
|
||||
const stats = target.startsWith(dist + path.sep) ? await stat(target).catch(() => undefined) : undefined
|
||||
|
||||
if (stats?.isFile()) {
|
||||
const file = Bun.file(target)
|
||||
|
||||
return new Response(file, {
|
||||
// Bun names .aac audio/x-aac, and only keeps the type on HEAD responses when it is set explicitly.
|
||||
headers: { "content-type": path.extname(target) === ".aac" ? "audio/aac" : file.type },
|
||||
})
|
||||
}
|
||||
|
||||
if (fallback(request, pathname))
|
||||
return new Response(index, { headers: { "content-type": "text/html;charset=utf-8" } })
|
||||
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
function decode(pathname: string) {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors vite's htmlFallbackMiddleware: only page loads get index.html, so a missing script or a fetch
|
||||
// asking for JSON still sees a 404.
|
||||
function fallback(request: Request, pathname: string) {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return false
|
||||
|
||||
if (pathname === "/favicon.ico") return false
|
||||
|
||||
// vite's index.html middleware never answers a script load with the page, so a missing chunk stays a 404.
|
||||
if (request.headers.get("sec-fetch-dest") === "script") return false
|
||||
|
||||
const accept = request.headers.get("accept")
|
||||
|
||||
return !accept || accept.includes("text/html") || accept.includes("*/*")
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.31",
|
||||
"version": "2.0.28",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -19,9 +19,9 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit && bun run test:browser",
|
||||
"test:unit": "bun test --conditions=solid --only-failures --isolate --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --isolate --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --isolate --preload ./happydom.ts ./src",
|
||||
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
|
||||
@@ -10,7 +10,7 @@ if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// a
|
||||
|
||||
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
|
||||
|
||||
// Production connects to its own origin, so fixture URLs must match the static server.
|
||||
// Production connects to its own origin, so fixture URLs must match the preview server.
|
||||
if (built) {
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
|
||||
@@ -21,13 +21,11 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
||||
const command = built
|
||||
? `bun run build && bun e2e/utils/serve.ts --host 127.0.0.1 --port ${port}`
|
||||
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
|
||||
const touchScroll = /mobile-timeline-scroll\.spec\.ts/
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: [
|
||||
@@ -61,27 +59,12 @@ export default defineConfig({
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
// CI retries failures, so the retry records the video; local runs have no retries.
|
||||
video: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||
// The PWA worker precaches the whole build in every test; e2e/service-worker covers it.
|
||||
serviceWorkers: "block",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
// The touch-scroll project exists only for scheduling: in the chromium project, mobile-timeline-scroll
|
||||
// is one long serial group that ends up running alone on a single worker. Projects queue in
|
||||
// declaration order, so this one runs the file's tests after every chromium group, spread across all
|
||||
// workers. `--project chromium`, UI mode and the VS Code extension's default first-project selection
|
||||
// therefore skip the file.
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
grepInvert: touchScroll,
|
||||
},
|
||||
{
|
||||
name: "chromium-touch-scroll",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
grep: touchScroll,
|
||||
fullyParallel: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createCommentSessionForTest, type LineComment } from "./comments"
|
||||
import type { LineComment } from "./comments"
|
||||
|
||||
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
const mod = await import("./comments")
|
||||
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||
})
|
||||
|
||||
function line(file: string, id: string, time: number): LineComment {
|
||||
return {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ComposerDropzone(props: {
|
||||
<div
|
||||
data-slot="session-dropzone-blur"
|
||||
data-visible={props.active}
|
||||
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-0 transition-[opacity,backdrop-filter] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=true]:opacity-100 data-[visible=true]:backdrop-blur-[1.5px] data-[visible=true]:will-change-[opacity,backdrop-filter] motion-reduce:transition-none"
|
||||
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
|
||||
style={{
|
||||
"-webkit-mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
|
||||
@@ -148,11 +148,8 @@ function session(input: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
setStatus: (_sessionID, status) => input.statuses?.push(status),
|
||||
// Like the data layer: admit locally, prepare, then send.
|
||||
prompt: async (value) => {
|
||||
input.calls.push("admit")
|
||||
await value.prepare?.()
|
||||
input.calls.push("send")
|
||||
input.calls.push("prompt")
|
||||
await input.prompt?.(value)
|
||||
|
||||
// The admitted inbox item, as the server returns it.
|
||||
@@ -203,21 +200,16 @@ describe("Composer submission", () => {
|
||||
})
|
||||
|
||||
test.each([
|
||||
// The prompt admits before the selection commits, so a steer shows as pending while it does.
|
||||
{
|
||||
current: { agent: "plan", model: { id: "old", providerID: "old" } },
|
||||
calls: ["admit", "switch-agent", "switch-model", "send"],
|
||||
},
|
||||
{ current: { agent: "plan", model: { id: "old", providerID: "old" } }, calls: ["switch-agent", "switch-model"] },
|
||||
// The model still commits: cached session state may lag behind an earlier switch.
|
||||
{
|
||||
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
|
||||
calls: ["admit", "switch-model", "send"],
|
||||
calls: ["switch-model"],
|
||||
},
|
||||
// A prompt commits a staged revert, which would delete the model switch made after its boundary. The revert
|
||||
// settles before admission, whose row it would also delete.
|
||||
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
|
||||
{
|
||||
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
|
||||
calls: ["switch-agent", "revert-commit", "switch-model", "admit", "send"],
|
||||
calls: ["switch-agent", "revert-commit", "switch-model"],
|
||||
},
|
||||
])("applies the selection before sending one captured value: $calls", async (row) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
@@ -229,7 +221,7 @@ describe("Composer submission", () => {
|
||||
await submitInput(active(state, target)).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual([...row.calls])
|
||||
expect(calls).toEqual([...row.calls, "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("ship it")
|
||||
expect(request.id).toMatch(/^msg_/)
|
||||
@@ -397,13 +389,12 @@ describe("Composer submission", () => {
|
||||
state.set(prompt)
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
const attempts: string[] = []
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
|
||||
const target = session({
|
||||
calls,
|
||||
calls: [],
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
@@ -427,9 +418,6 @@ describe("Composer submission", () => {
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
// A retried admission reuses its submission's selection commit.
|
||||
const submitted = ["admit", "switch-agent", "switch-model", "send", "admit", "send"]
|
||||
expect(calls).toEqual([...submitted, ...submitted])
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toEqual(prompt)
|
||||
expect(state.context.items()).toMatchObject([
|
||||
|
||||
@@ -508,16 +508,8 @@ async function sendPrompt(
|
||||
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
|
||||
}
|
||||
|
||||
const commit = () => (value.delivery === "steer" ? applySelection(session, value.selection, track, settle) : settle())
|
||||
// The data layer admits the prompt locally before it commits the selection, so a steer shows as pending while the
|
||||
// selection commits. A staged revert settles before admission instead: its committed echo removes every local row
|
||||
// from its boundary on. Only an idle session stages one, and a prompt into it starts work rather than showing as
|
||||
// pending.
|
||||
const staged = !!session.current()?.revert
|
||||
|
||||
if (staged) await commit()
|
||||
// A retried admission reuses the first commit rather than switching again.
|
||||
let committed: Promise<void> | undefined
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
|
||||
else await settle()
|
||||
|
||||
const admission = {
|
||||
id: value.id,
|
||||
@@ -539,7 +531,6 @@ async function sendPrompt(
|
||||
agent: value.selection.agent,
|
||||
model: selectionModel(value.selection),
|
||||
},
|
||||
prepare: staged ? undefined : () => (committed ??= commit()),
|
||||
}
|
||||
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
|
||||
@@ -107,11 +107,6 @@
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* A collapsing frame's elevation outlives its height as a flat line over the panel beside it, so it fades out. */
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] #review-panel {
|
||||
animation: side-panel-frame-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@@ -142,7 +137,6 @@
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-review-content"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-slot="session-side-region-presence"] #review-panel,
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
@@ -179,7 +173,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Like `side-panel-frame-out`, the collapsing terminal frame drops its elevation. */
|
||||
@keyframes side-terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
@@ -187,13 +180,6 @@
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-panel-frame-out {
|
||||
to {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, ErrorBoundary, Show, type ParentProps } from "solid-js"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -12,7 +12,6 @@ import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import NewSession from "@/new-session/screen"
|
||||
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
|
||||
export function DraftRoute() {
|
||||
@@ -68,25 +67,15 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
|
||||
</SessionRouteFrame>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised>
|
||||
<ErrorPage error={error} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ErrorBoundary>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ export function createProviderConnectionController(options: {
|
||||
keyProvider?: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
/** Skips auto-selecting a lone method; the caller starts it. */
|
||||
manual?: () => boolean
|
||||
/** Picks the method to start without asking when the integration exposes several. */
|
||||
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
|
||||
/** Runs after the catalogs refresh; returning false keeps the dialog on a retryable error. */
|
||||
prepare?: (active: () => boolean) => Promise<boolean>
|
||||
pollInterval?: number
|
||||
@@ -148,9 +148,12 @@ export function createProviderConnectionController(options: {
|
||||
)
|
||||
|
||||
const autoIndex = createMemo(() => {
|
||||
if (integration.loading || options.manual?.()) return undefined
|
||||
if (integration.loading) return undefined
|
||||
const values = methods()
|
||||
|
||||
return methods().length === 1 ? 0 : undefined
|
||||
if (values.length === 1) return 0
|
||||
|
||||
return options.autoSelect?.(values)
|
||||
})
|
||||
|
||||
type Action =
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { List } from "@opencode/ui/list"
|
||||
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { TextField } from "@opencode/ui/text-field"
|
||||
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -43,6 +43,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
CONSOLE_INTEGRATION,
|
||||
CONSOLE_PROVIDERS,
|
||||
consoleIntegration,
|
||||
createProviderConnectionController,
|
||||
@@ -52,11 +53,6 @@ import {
|
||||
import { ConsoleAuthorization } from "./console"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
import { authServerName, RemoteAuthNotice } from "./remote"
|
||||
import { providerAccounts, type ProviderAccount } from "@/settings/providers/accounts"
|
||||
import { AccountNameInput } from "@/settings/providers/account-name"
|
||||
import { useProviderAccountActions } from "@/settings/providers/account-actions"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import "./models.css"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
@@ -93,12 +89,10 @@ export const DialogConnectProvider: Component<{
|
||||
modelProvider?: { id: string; name: string }
|
||||
authorization: boolean
|
||||
chatgptWelcome: boolean
|
||||
picked: boolean
|
||||
}>({
|
||||
completed: false,
|
||||
authorization: false,
|
||||
chatgptWelcome: false,
|
||||
picked: false,
|
||||
})
|
||||
|
||||
const language = useLanguage()
|
||||
@@ -118,7 +112,6 @@ export const DialogConnectProvider: Component<{
|
||||
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
setState("picked", true)
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
@@ -135,7 +128,6 @@ export const DialogConnectProvider: Component<{
|
||||
{(provider) => (
|
||||
<ProviderConnection
|
||||
provider={provider}
|
||||
accounts={state.picked}
|
||||
directory={props.directory}
|
||||
defaultLocation={props.defaultLocation}
|
||||
onBack={reset}
|
||||
@@ -145,7 +137,8 @@ export const DialogConnectProvider: Component<{
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true)
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
@@ -184,8 +177,7 @@ export const DialogConnectProvider: Component<{
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
|
||||
consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
|
||||
}}
|
||||
>
|
||||
@@ -240,11 +232,29 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||
|
||||
// Only a stored credential hides a provider: environment and config connections can still be
|
||||
// replaced by a sign-in. OpenCode Zen stays until a Console account (not a key) is connected.
|
||||
const consoleAccount = createMemo(() =>
|
||||
integrations
|
||||
.list()
|
||||
.find((integration) => integration.id === CONSOLE_INTEGRATION)
|
||||
?.connections.some((connection) => connection.type === "credential" && connection.method === "oauth"),
|
||||
)
|
||||
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
const query = store.filter.trim().toLowerCase()
|
||||
|
||||
const values = [custom(), ...integrations.list()]
|
||||
const values = [
|
||||
custom(),
|
||||
...integrations
|
||||
.list()
|
||||
.filter((integration) =>
|
||||
integration.id === CONSOLE_INTEGRATION
|
||||
? !consoleAccount()
|
||||
: !integration.connections.some((connection) => connection.type === "credential"),
|
||||
),
|
||||
]
|
||||
|
||||
if (!query) return values
|
||||
|
||||
@@ -270,10 +280,6 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
)
|
||||
|
||||
const rows = createMemo(() => [...popular(), ...other()])
|
||||
|
||||
const accountCount = (provider: { id: string }) =>
|
||||
providerAccounts(integrations.list().find((integration) => integration.id === provider.id)).length
|
||||
|
||||
let picker: HTMLDivElement | undefined
|
||||
let search: HTMLInputElement | undefined
|
||||
|
||||
@@ -350,18 +356,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
>
|
||||
<ProviderModelIcon provider={provider} class="shrink-0 text-v2-icon-icon-base" />
|
||||
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
||||
<Show when={accountCount(provider) > 0}>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
class="shrink-0"
|
||||
aria-label={language.plural("dialog.provider.accounts.count", accountCount(provider), {
|
||||
count: accountCount(provider),
|
||||
})}
|
||||
>
|
||||
{accountCount(provider)}
|
||||
</Badge>
|
||||
</Show>
|
||||
<Show when={CONSOLE_PROVIDERS.has(provider.id) && accountCount(provider) === 0}>
|
||||
<Show when={CONSOLE_PROVIDERS.has(provider.id)}>
|
||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
||||
{language.t(
|
||||
provider.id === "opencode"
|
||||
@@ -414,7 +409,6 @@ function ProviderConnection(props: {
|
||||
onConnected?: (methodID?: string) => void
|
||||
onFirstConnection: (provider: { id: string; name: string }) => void
|
||||
onAuthorization: (authorization: boolean) => void
|
||||
accounts?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
@@ -430,7 +424,6 @@ function ProviderConnection(props: {
|
||||
const location = () => (initialDirectory ? { directory: initialDirectory } : undefined)
|
||||
const providers = useProviders(directory)
|
||||
const integrations = useIntegrations(directory)
|
||||
const accountActions = useProviderAccountActions(directory)
|
||||
const integrationID = consoleIntegration(props.provider)
|
||||
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
|
||||
const remote = isConsole && authServerName(sdk.server) !== undefined
|
||||
@@ -444,15 +437,7 @@ function ProviderConnection(props: {
|
||||
catalogPending: boolean
|
||||
selectedModel: string
|
||||
collapsed: Record<string, boolean>
|
||||
adding: boolean
|
||||
direct: boolean
|
||||
confirming?: string
|
||||
switching?: string
|
||||
activating?: string
|
||||
renaming?: string
|
||||
}>({
|
||||
adding: false,
|
||||
direct: false,
|
||||
copied: false,
|
||||
copyFailed: false,
|
||||
models: false,
|
||||
@@ -463,23 +448,18 @@ function ProviderConnection(props: {
|
||||
collapsed: {},
|
||||
})
|
||||
|
||||
// Sorted by name so rows stay put when the server moves the active account first.
|
||||
const serverAccounts = () =>
|
||||
providerAccounts(integrations.list().find((integration) => integration.id === props.provider))
|
||||
|
||||
const accounts = () => serverAccounts().toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
|
||||
|
||||
const activeAccount = () => state.activating ?? serverAccounts()[0]?.id
|
||||
|
||||
const showAccounts = () => !!props.accounts && !state.adding && accounts().length > 0
|
||||
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => integrationID,
|
||||
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
|
||||
// exactly as before; only the sign-in is shared with the Console.
|
||||
keyProvider: () => props.provider,
|
||||
directory,
|
||||
manual: () => isConsole || (!!props.accounts && accounts().length > 0),
|
||||
autoSelect: (methods) => {
|
||||
if (!isConsole) return undefined
|
||||
const index = methods.findIndex((method) => method.type === "oauth")
|
||||
|
||||
return index === -1 ? undefined : index
|
||||
},
|
||||
prepare: isConsole ? prepareConsoleCatalog : undefined,
|
||||
pollInterval: isConsole ? 500 : undefined,
|
||||
onComplete: () => {
|
||||
@@ -487,7 +467,9 @@ function ProviderConnection(props: {
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
|
||||
global.models.show(
|
||||
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
|
||||
if (state.catalogPending) {
|
||||
setState("noModels", true)
|
||||
@@ -611,9 +593,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
createEffect(() => {
|
||||
const current = controller.auth.state()
|
||||
props.onAuthorization(
|
||||
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
|
||||
)
|
||||
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
|
||||
})
|
||||
|
||||
const provider = createMemo(() => ({
|
||||
@@ -666,17 +646,13 @@ function ProviderConnection(props: {
|
||||
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
|
||||
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
|
||||
|
||||
// The Console device flow owns the dialog from the first frame until the catalogs are loaded.
|
||||
const consoleSignIn = () =>
|
||||
isConsole &&
|
||||
!state.noModels &&
|
||||
controller.currentMethod()?.type === "oauth" &&
|
||||
controller.currentMethod()?.type !== "key" &&
|
||||
controller.auth.state() !== "error" &&
|
||||
controller.authorization()?.mode === "auto"
|
||||
|
||||
const consoleStarting = () => controller.currentMethod()?.type === "oauth" && controller.auth.state() === "pending"
|
||||
|
||||
const consoleIntro = () =>
|
||||
isConsole && !controller.loading() && (controller.methodIndex() === undefined || consoleStarting())
|
||||
(controller.busy() || controller.authorization()?.mode === "auto")
|
||||
|
||||
function AuthFormView() {
|
||||
const defaults = providerFormDefaults(controller.currentMethod()?.form)
|
||||
@@ -913,19 +889,15 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (
|
||||
props.accounts &&
|
||||
state.adding &&
|
||||
accounts().length > 0 &&
|
||||
(state.direct || controller.methodIndex() === undefined)
|
||||
) {
|
||||
controller.auth.reset()
|
||||
setState({ adding: false, direct: false })
|
||||
// The API key path for the Console is an escape hatch below the sign-in flow, so
|
||||
// "back" returns to the sign-in rather than leaving the provider.
|
||||
if (isConsole && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
|
||||
void controller.auth.select(oauthIndex())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined) {
|
||||
if (!isConsole && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
|
||||
return
|
||||
@@ -980,7 +952,7 @@ function ProviderConnection(props: {
|
||||
|
||||
function ErrorRow() {
|
||||
return (
|
||||
<div class="flex flex-col items-start" classList={{ "gap-3": !isConsole, "gap-4": isConsole }}>
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
|
||||
<span role="alert">
|
||||
@@ -989,8 +961,8 @@ function ProviderConnection(props: {
|
||||
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant={isConsole ? undefined : "neutral"} onClick={() => void controller.auth.retry()}>
|
||||
{language.t(isConsole ? "provider.connect.console.retry" : "common.retry")}
|
||||
<Button variant="neutral" onClick={() => void controller.auth.retry()}>
|
||||
{language.t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -1029,15 +1001,21 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
classList={{ "pb-1": isConsole }}
|
||||
>
|
||||
<p>
|
||||
{isConsole
|
||||
? language.t("provider.connect.console.serviceKeyDescription")
|
||||
: language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
</p>
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Show
|
||||
when={isConsole}
|
||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
>
|
||||
<div>
|
||||
{language.t("provider.connect.console.apiKey.description")}{" "}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/console"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.link")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
@@ -1062,12 +1040,7 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contrast"
|
||||
class={isConsole ? "!px-3" : undefined}
|
||||
data-action="provider-connect-submit"
|
||||
>
|
||||
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -1170,166 +1143,18 @@ function ProviderConnection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const addAccount = () => {
|
||||
setState({ adding: true, direct: false, confirming: undefined })
|
||||
|
||||
// Go signs in through the Console, which would add a Console account, so Go adds a key instead.
|
||||
const index =
|
||||
props.provider === "opencode-go" ? keyIndex() : !isConsole && controller.methods().length === 1 ? 0 : -1
|
||||
|
||||
if (index === -1) return
|
||||
setState("direct", true)
|
||||
void controller.auth.select(index)
|
||||
}
|
||||
|
||||
const activateAccount = async (account: ProviderAccount) => {
|
||||
if (activeAccount() === account.id || state.switching) return
|
||||
setState({ switching: account.id, activating: account.id, confirming: undefined })
|
||||
await accountActions.activate({ integrationID: props.provider, provider: provider().name, account })
|
||||
setState({ switching: undefined, activating: undefined })
|
||||
}
|
||||
|
||||
const removeAccount = async (account: ProviderAccount) => {
|
||||
setState({ switching: account.id, confirming: undefined })
|
||||
await accountActions.remove({
|
||||
integrationID: props.provider,
|
||||
provider: provider().name,
|
||||
account,
|
||||
final: accounts().length === 1,
|
||||
})
|
||||
setState("switching", undefined)
|
||||
}
|
||||
|
||||
function AccountsView() {
|
||||
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
|
||||
function ConsoleApiKeySwitch() {
|
||||
return (
|
||||
<div data-component="provider-accounts" class="flex flex-col gap-3">
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("dialog.provider.accounts.connected")}
|
||||
</div>
|
||||
<div role="radiogroup" aria-label={language.t("dialog.provider.accounts.connected")} class="flex flex-col">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Show
|
||||
when={state.confirming !== account.id}
|
||||
fallback={
|
||||
<div class="flex min-h-9 items-center gap-2 rounded-md px-3 text-[13px] leading-5 tracking-[-0.04px]">
|
||||
<span role="alert" class="min-w-0 flex-1 truncate font-[440] text-v2-text-text-base">
|
||||
{language.t("dialog.provider.accounts.removeConfirm", { account: account.label })}
|
||||
</span>
|
||||
<Button variant="ghost-muted" onClick={() => setState("confirming", undefined)}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="neutral" onClick={() => void removeAccount(account)}>
|
||||
{language.t("dialog.provider.accounts.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="group relative flex min-h-9 items-center rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<Show
|
||||
when={state.renaming !== account.id}
|
||||
fallback={
|
||||
<div class="flex min-h-9 min-w-0 flex-1 items-center px-3">
|
||||
<AccountNameInput
|
||||
value={account.label}
|
||||
class="-ms-1 max-w-full text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
onSave={(label) => accountActions.rename(account, label)}
|
||||
onClose={() => setState("renaming", undefined)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeAccount() === account.id}
|
||||
class="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-3 text-left text-[13px] leading-5 tracking-[-0.04px] focus-visible:outline-none"
|
||||
disabled={state.switching !== undefined}
|
||||
onClick={() => void activateAccount(account)}
|
||||
>
|
||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-base">{account.label}</span>
|
||||
<Show when={activeAccount() === account.id}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex shrink-0 items-center pe-1.5">
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="outline-dots" />}
|
||||
disabled={state.switching !== undefined}
|
||||
aria-label={language.t("dialog.provider.accounts.options", { account: account.label })}
|
||||
/>
|
||||
{/* The modal dialog aria-hides anything added outside it, this portal included, a frame
|
||||
after it opens. Kobalte leaves a node marked as a top layer visible. */}
|
||||
<Menu.Portal
|
||||
ref={(el) => {
|
||||
el.dataset.reactAriaTopLayer = "true"
|
||||
}}
|
||||
>
|
||||
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
|
||||
<Menu.Item onSelect={() => setState({ renaming: account.id, confirming: undefined })}>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => setState({ confirming: account.id, renaming: undefined })}>
|
||||
{language.t("dialog.provider.accounts.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
<Button class="-mt-1 ms-2.5 self-start !px-3" disabled={state.switching !== undefined} onClick={addAccount}>
|
||||
{language.t("dialog.provider.accounts.add")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleIntro() {
|
||||
return (
|
||||
<div class="flex flex-col items-start gap-5 px-3">
|
||||
<p class="text-[13px] leading-5 text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
|
||||
<div data-component="console-service-account" class="flex h-7 items-center gap-1 px-3 pt-5 text-[13px]">
|
||||
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
|
||||
<Button
|
||||
size="large"
|
||||
class="!px-3"
|
||||
variant="contrast"
|
||||
disabled={oauthIndex() === -1 || consoleStarting()}
|
||||
aria-busy={consoleStarting()}
|
||||
onClick={() => void controller.auth.select(oauthIndex())}
|
||||
variant="ghost-muted"
|
||||
data-action="provider-connect-api-key"
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
<Show when={consoleStarting()}>
|
||||
<span class="absolute inset-0 flex items-center justify-center gap-1.5">
|
||||
<Loader />
|
||||
<span>{language.t("provider.connect.console.openingBrowser")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<span aria-hidden={consoleStarting() ? "true" : undefined} classList={{ "opacity-0": consoleStarting() }}>
|
||||
{language.t("provider.connect.console.continue")}
|
||||
</span>
|
||||
{language.t("provider.connect.console.useApiKey")}
|
||||
</Button>
|
||||
<Show when={keyIndex() !== -1}>
|
||||
<div data-component="console-service-account" class="flex h-7 items-center gap-1 text-[13px]">
|
||||
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
|
||||
<Button
|
||||
variant="ghost-muted"
|
||||
data-action="provider-connect-api-key"
|
||||
disabled={consoleStarting()}
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.console.useApiKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1527,17 +1352,7 @@ function ProviderConnection(props: {
|
||||
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<Switch>
|
||||
<Match when={showAccounts()}>{provider().name}</Match>
|
||||
<Match when={isConsole && controller.auth.state() === "error"}>
|
||||
{language.t("provider.connect.opencode.errorTitle")}
|
||||
</Match>
|
||||
<Match when={consoleSignIn()}>
|
||||
{language.t(
|
||||
props.provider === "opencode-go"
|
||||
? "provider.connect.console.titleGo"
|
||||
: "provider.connect.console.title",
|
||||
)}
|
||||
</Match>
|
||||
<Match when={consoleSignIn()}>{language.t("provider.connect.console.title")}</Match>
|
||||
<Match
|
||||
when={
|
||||
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
|
||||
@@ -1545,9 +1360,6 @@ function ProviderConnection(props: {
|
||||
>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={props.provider === "opencode"}>
|
||||
{language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</DialogTitle>
|
||||
@@ -1578,12 +1390,6 @@ function ProviderConnection(props: {
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={showAccounts() && controller.methodIndex() === undefined}>
|
||||
<AccountsView />
|
||||
</Match>
|
||||
<Match when={consoleIntro()}>
|
||||
<ConsoleIntro />
|
||||
</Match>
|
||||
<Match when={controller.busy()}>
|
||||
<div class="px-3">
|
||||
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
|
||||
@@ -1610,6 +1416,17 @@ function ProviderConnection(props: {
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show
|
||||
when={
|
||||
isConsole &&
|
||||
!controller.loading() &&
|
||||
!state.noModels &&
|
||||
controller.currentMethod()?.type !== "key" &&
|
||||
keyIndex() !== -1
|
||||
}
|
||||
>
|
||||
<ConsoleApiKeySwitch />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -56,7 +56,9 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-header:hover {
|
||||
[data-component="first-provider-models"]
|
||||
.provider-model-groups--dialog
|
||||
.provider-model-group-trigger:not(:disabled):hover {
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ function SelectorStory(props: { plan: boolean }) {
|
||||
{models.find((model) => model.id === current())?.name}
|
||||
</Button>
|
||||
)}
|
||||
all={() => models}
|
||||
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
groups={(items) => [{ category: "openai", items }]}
|
||||
current={`openai:${current()}`}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ProviderModelIcon,
|
||||
ProviderModelSections,
|
||||
} from "@/providers/models/provider-group"
|
||||
import { consoleProviderName } from "@/providers/catalog/console"
|
||||
import "@/settings/settings.css"
|
||||
import "./select-dialog.css"
|
||||
|
||||
@@ -300,7 +299,6 @@ export function ModelSelectorPopover(props: {
|
||||
fallback={
|
||||
<ModelSelectorPopoverView
|
||||
trigger={props.trigger}
|
||||
all={controller.all}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
@@ -496,7 +494,6 @@ function createModelSelectorController(input: {
|
||||
|
||||
export function ModelSelectorPopoverView(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
all: () => ModelItem[]
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
@@ -515,21 +512,6 @@ export function ModelSelectorPopoverView(props: {
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey]
|
||||
|
||||
const managed = createMemo(() => consoleModelGroup(props.all()))
|
||||
const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id)))
|
||||
const canonical = (provider: ModelItem["provider"]) => provider.canonical ?? provider.id.replace(/^console-/, "")
|
||||
|
||||
// The workspace badge only disambiguates a managed provider that is also connected directly.
|
||||
const direct = createMemo(
|
||||
() =>
|
||||
new Set(
|
||||
props
|
||||
.all()
|
||||
.filter((item) => !managedIDs().has(item.provider.id))
|
||||
.map((item) => canonical(item.provider)),
|
||||
),
|
||||
)
|
||||
|
||||
const initialActive = () => {
|
||||
const selected = props.current
|
||||
const options = keys()
|
||||
@@ -695,26 +677,8 @@ export function ModelSelectorPopoverView(props: {
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel
|
||||
class="gap-1.5 pe-3"
|
||||
style={{ "padding-inline-start": group.items[0].provider.id === "opencode" ? "10px" : "12px" }}
|
||||
>
|
||||
<ProviderModelIcon provider={group.items[0].provider} class="shrink-0" />
|
||||
<Show
|
||||
when={managedIDs().has(group.category) ? managed() : undefined}
|
||||
fallback={<span class="min-w-0 truncate">{group.items[0].provider.name}</span>}
|
||||
>
|
||||
{(workspace) => (
|
||||
<>
|
||||
<span class="min-w-0 truncate">
|
||||
{consoleProviderName(workspace(), group.items[0].provider.name)}
|
||||
</span>
|
||||
<Show when={direct().has(canonical(group.items[0].provider))}>
|
||||
<Badge class="shrink-0">{workspace().workspace}</Badge>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Menu.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</Menu.GroupLabel>
|
||||
<Menu.RadioGroup value={props.current}>
|
||||
<For each={group.items}>
|
||||
|
||||
@@ -269,15 +269,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const current = createMemo(() => {
|
||||
if (!configuredModel.ready()) return
|
||||
|
||||
const session = durable()
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => {
|
||||
const session = durable()
|
||||
|
||||
// Catalog availability must not replace a user's choice with a default on the next submission.
|
||||
const item =
|
||||
scope()?.model ??
|
||||
(session?.model && (!session.agent || session.agent === agent.current()?.name)
|
||||
? { providerID: session.model.providerID, modelID: session.model.id }
|
||||
: undefined) ??
|
||||
firstModel(() => agent.current()?.model, fallback)
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const model = session?.model
|
||||
|
||||
return model && { providerID: model.providerID, modelID: model.id }
|
||||
},
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
|
||||
if (!item) return
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -75,30 +75,17 @@ export function SideRegion(props: {
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
)
|
||||
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
let selectionEvent: Event | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (open()) return
|
||||
const active = document.activeElement
|
||||
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !!params.id}>
|
||||
<aside
|
||||
ref={root}
|
||||
id="review-panel"
|
||||
tabIndex={-1}
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
|
||||
classList={{
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"h-full min-h-0": props.stacked,
|
||||
@@ -159,7 +146,12 @@ export function SideRegion(props: {
|
||||
props.region.select(value)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||
{/* Tabs and actions share the review toggle's row: the session header's 48px, or the 51px
|
||||
above a side dock's divider. */}
|
||||
<div
|
||||
class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center"
|
||||
style={{ "--tabs-bar-height": props.stacked ? "51px" : "48px" }}
|
||||
>
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
tabList = el
|
||||
|
||||
@@ -42,10 +42,6 @@ export const dict = {
|
||||
"command.palette": "Command palette",
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.file.open": "Open file",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
@@ -122,24 +118,11 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
|
||||
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
"dialog.provider.accounts.count.one": "{{count}} account",
|
||||
"dialog.provider.accounts.count.other": "{{count}} accounts",
|
||||
"dialog.provider.accounts.add": "Add another account",
|
||||
"dialog.provider.accounts.connected": "Connected accounts",
|
||||
"dialog.provider.accounts.removeConfirm": "Remove {{account}}?",
|
||||
"dialog.provider.accounts.remove": "Remove",
|
||||
"dialog.provider.accounts.options": "{{account}} options",
|
||||
|
||||
"provider.connect.title": "Connect {{provider}}",
|
||||
"provider.connect.opencode.name": "OpenCode Console",
|
||||
"provider.connect.opencode.freeName": "OpenCode Free",
|
||||
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
|
||||
"provider.connect.console.title": "Connecting to OpenCode Console",
|
||||
"provider.connect.console.titleGo": "Connecting to OpenCode Go",
|
||||
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
|
||||
"provider.connect.console.continue": "Continue in the browser",
|
||||
"provider.connect.console.retry": "Try again",
|
||||
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
|
||||
"provider.connect.console.instructions":
|
||||
"Continue in your browser. Confirm the code shown there matches the one below.",
|
||||
"provider.connect.console.deviceCode": "Device code",
|
||||
@@ -208,6 +191,9 @@ export const dict = {
|
||||
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
|
||||
"provider.connect.oauth.openBrowser": "Open browser",
|
||||
"provider.connect.oauth.expired": "Authorization expired",
|
||||
"provider.connect.console.apiKey.description":
|
||||
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
|
||||
"provider.connect.console.apiKey.link": "Open the Console",
|
||||
"provider.connect.toast.connected.title": "{{provider}} connected",
|
||||
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
||||
|
||||
@@ -567,7 +553,6 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Jump to latest",
|
||||
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.title": "Question",
|
||||
"session.websearch.title": "Third-party web search",
|
||||
"session.websearch.description": "Select the search provider agents use to search the web",
|
||||
"session.websearch.provider": "Search provider",
|
||||
@@ -751,8 +736,6 @@ export const dict = {
|
||||
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
|
||||
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
|
||||
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"project.settings.folderMissing.title": "Project folder not found",
|
||||
"project.settings.folderMissing.description": "Restore the folder or close this project.",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
@@ -796,8 +779,6 @@ export const dict = {
|
||||
"settings.timeline.description.text-only": "Hide all activity.",
|
||||
"settings.timeline.description.custom": "Uses advanced settings.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.row.turnDiffs.title": "Changed files",
|
||||
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.group": "Group",
|
||||
@@ -934,7 +915,8 @@ export const dict = {
|
||||
"settings.providers.account.manage": "Manage {{provider}} accounts",
|
||||
"settings.providers.account.group": "Accounts",
|
||||
"settings.providers.account.add": "Add account",
|
||||
"settings.providers.account.name": "Account name",
|
||||
"settings.providers.account.remove": "Remove account…",
|
||||
"settings.providers.account.active": "Active",
|
||||
"settings.providers.account.switched.title": "{{provider}} account switched",
|
||||
"settings.providers.account.switched.description": "Now using {{account}}.",
|
||||
"settings.providers.account.removed.title": "{{account}} removed",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCommand, type CommandOption } from "@/shell/commands/command"
|
||||
import { adjacentTabKey } from "@/shell/titlebar/tab-order"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { useFile, selectionFromLines, type FileSelection } from "@/workspaces/files/model"
|
||||
@@ -81,22 +80,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
return entry && !entry.tab.pinned ? entry.key : undefined
|
||||
}
|
||||
|
||||
const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1
|
||||
|
||||
// Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there.
|
||||
const extensionScoped = (target: EventTarget | null) =>
|
||||
target instanceof Element &&
|
||||
host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope))
|
||||
|
||||
const sideFocused = (target: EventTarget | null) => {
|
||||
const element =
|
||||
target instanceof Element && target !== document.body && target !== document.documentElement
|
||||
? target
|
||||
: document.activeElement
|
||||
|
||||
return element instanceof Element && !!element.closest("#review-panel") && !extensionScoped(element)
|
||||
}
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
|
||||
@@ -215,28 +203,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (tab) actions.region.close(tab)
|
||||
}
|
||||
|
||||
const selectAdjacentSideTab = (offset: -1 | 1) => {
|
||||
const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset)
|
||||
|
||||
if (!key) return
|
||||
|
||||
actions.region.select(key)
|
||||
|
||||
const panel = document.getElementById("review-panel")
|
||||
|
||||
if (!panel) return
|
||||
|
||||
const active = document.activeElement
|
||||
|
||||
if (
|
||||
active instanceof HTMLElement &&
|
||||
panel.contains(active) &&
|
||||
!active.closest('[role="tab"][aria-selected="false"], [inert], .hidden')
|
||||
)
|
||||
return
|
||||
;(panel.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? panel).focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const addSelection = () => {
|
||||
const path = activeFile()
|
||||
|
||||
@@ -386,7 +352,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
const fileCmds = () => {
|
||||
const tab = closableTab()
|
||||
const cycle = cycleSideTabs()
|
||||
|
||||
return [
|
||||
fileCommand({
|
||||
@@ -405,24 +370,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
when: (event) => !extensionScoped(event.target),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
cycle &&
|
||||
fileCommand({
|
||||
id: "file.tab.prev",
|
||||
title: "",
|
||||
keybind: settings.keybinds.get("tab.prev") ?? "mod+option+ArrowLeft,ctrl+shift+tab",
|
||||
hidden: true,
|
||||
when: (event) => sideFocused(event.target),
|
||||
onSelect: () => selectAdjacentSideTab(-1),
|
||||
}),
|
||||
cycle &&
|
||||
fileCommand({
|
||||
id: "file.tab.next",
|
||||
title: "",
|
||||
keybind: settings.keybinds.get("tab.next") ?? "mod+option+ArrowRight,ctrl+tab",
|
||||
hidden: true,
|
||||
when: (event) => sideFocused(event.target),
|
||||
onSelect: () => selectAdjacentSideTab(1),
|
||||
}),
|
||||
].filter((v) => !!v)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useSpring } from "@opencode/ui/motion-spring"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode/client/promise"
|
||||
@@ -19,7 +18,7 @@ import { useCommand } from "@/shell/commands/command"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
const IS_MAC = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
type QuestionField = FormStringField | FormMultiselectField
|
||||
|
||||
@@ -27,12 +26,12 @@ function questionField(field: FormInfo["fields"][number]): field is QuestionFiel
|
||||
return field.type === "string" || field.type === "multiselect"
|
||||
}
|
||||
|
||||
function Mark(props: { multi: boolean; picked: boolean }) {
|
||||
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
|
||||
return (
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
|
||||
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
|
||||
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check" size="normal" />
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
@@ -44,7 +43,6 @@ function Option(props: {
|
||||
picked: boolean
|
||||
label: string
|
||||
description?: string
|
||||
shortcut?: string
|
||||
disabled: boolean
|
||||
ref?: (el: HTMLButtonElement) => void
|
||||
onFocus?: VoidFunction
|
||||
@@ -58,20 +56,15 @@ function Option(props: {
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
aria-keyshortcuts={props.shortcut}
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">
|
||||
<bdi>{props.label}</bdi>
|
||||
</span>
|
||||
<span data-slot="option-label">{props.label}</span>
|
||||
<Show when={props.description}>
|
||||
<span data-slot="option-description">
|
||||
<bdi>{props.description}</bdi>
|
||||
</span>
|
||||
<span data-slot="option-description">{props.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
@@ -100,9 +93,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tab: cached?.tab ?? 0,
|
||||
answers: cached?.answers ?? [],
|
||||
custom: cached?.custom ?? [],
|
||||
customOn: cached?.customOn ?? [],
|
||||
answers: cached?.answers ?? ([] as string[][]),
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
minimized: false,
|
||||
@@ -112,7 +105,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
let root: HTMLDivElement | undefined
|
||||
let optionsRef: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let customInput: HTMLInputElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
let focusFrame: number | undefined
|
||||
@@ -125,13 +117,13 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
if (total() === 1) return language.t("session.question.title")
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.question.custom.answerPlaceholder")
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
@@ -263,7 +255,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
})
|
||||
})
|
||||
|
||||
const fail = (err: Error) => showToast({ title: language.t("common.requestFailed"), description: err.message })
|
||||
const fail = (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answer: FormAnswer) =>
|
||||
@@ -279,8 +274,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () =>
|
||||
serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
@@ -326,9 +320,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
||||
const pick = (answer: string) => {
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
setStore("answers", store.tab, [answer])
|
||||
setStore("customOn", store.tab, false)
|
||||
|
||||
if (custom) setStore("custom", store.tab, answer)
|
||||
|
||||
if (!custom) setStore("customOn", store.tab, false)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
@@ -348,7 +345,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
customInput?.focus()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -359,7 +355,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
if (next) {
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
customInput?.focus()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -371,6 +366,15 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const move = (step: number) => {
|
||||
if (store.editing || sending()) return
|
||||
focus(store.focus + step)
|
||||
@@ -413,29 +417,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
|
||||
if (store.editing) return
|
||||
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (/^[1-9]$/.test(event.key)) {
|
||||
if (event.repeat || event.isComposing || store.minimized || sending()) return
|
||||
const index = Number(event.key) - 1
|
||||
|
||||
if (index >= count()) return
|
||||
event.preventDefault()
|
||||
|
||||
if (index === options().length) {
|
||||
customToggle()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
selectOption(index)
|
||||
focus(index)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
@@ -465,6 +450,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (sending()) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
customOpen()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const opt = options()[optIndex]
|
||||
|
||||
if (!opt) return
|
||||
@@ -485,6 +476,24 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const focusCustom = (el: HTMLTextAreaElement) => {
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
resizeInput(el)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const toggleCustomMark = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
customToggle()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (sending()) return
|
||||
|
||||
@@ -496,22 +505,28 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
return
|
||||
}
|
||||
|
||||
jump(store.tab + 1)
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (sending()) return
|
||||
|
||||
if (store.tab <= 0) return
|
||||
jump(store.tab - 1)
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending() || tab === store.tab) return
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
|
||||
if (optionsRef) setStore("optionsHeight", optionsRef.scrollHeight)
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
@@ -549,57 +564,33 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-top">
|
||||
<div data-slot="question-header-title">
|
||||
<bdi>{summary()}</bdi>
|
||||
</div>
|
||||
<div data-slot="question-header-actions">
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={language.t("ui.tool.questions.numbered", { number: i() + 1 })}
|
||||
aria-current={i() === store.tab ? "step" : undefined}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<IconButton
|
||||
icon={<Icon name="outline-chevron-down" size="normal" />}
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||
onClick={store.minimized ? restore : minimize}
|
||||
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="question-details">
|
||||
<div
|
||||
data-slot="question-text"
|
||||
style={{
|
||||
display: store.minimized ? "-webkit-box" : undefined,
|
||||
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
<bdi>{question()?.question}</bdi>
|
||||
</div>
|
||||
<Show when={!store.minimized}>
|
||||
<div data-slot="question-hint">
|
||||
{language.t(multi() ? "ui.question.multiHint" : "ui.question.singleHint")}
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-header-actions">
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={language.t("ui.tool.questions.numbered", { number: i() + 1 })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<IconButton
|
||||
icon={<Icon name="chevron-down" size="small" />}
|
||||
variant="ghost"
|
||||
disabled={sending()}
|
||||
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||
onClick={store.minimized ? restore : minimize}
|
||||
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@@ -615,7 +606,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
value={
|
||||
<>
|
||||
{language.t("ui.common.back")}
|
||||
<span style={{ opacity: 0.6 }}>{backShortcut()}</span>
|
||||
<span class="opacity-60">{backShortcut()}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -630,38 +621,41 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t(last() ? "ui.common.submit" : "ui.common.next")}
|
||||
<span style={{ opacity: 0.6 }}>{submitShortcut()}</span>
|
||||
</>
|
||||
}
|
||||
<Button
|
||||
variant={last() ? "submit" : "neutral"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
<Button
|
||||
variant={last() ? "submit" : "neutral"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{language.t(last() ? "ui.common.submit" : "ui.common.next")}
|
||||
<span data-slot="question-submit-shortcut" aria-hidden="true">
|
||||
{submitShortcut()}
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
<span data-slot="question-submit-shortcut" aria-hidden="true" class="text-11-medium opacity-60">
|
||||
{submitShortcut()}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
style={{
|
||||
display: store.minimized ? "-webkit-box" : undefined,
|
||||
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={!store.minimized}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => (optionsRef = el)}
|
||||
data-slot="question-options"
|
||||
role={multi() ? "group" : "radiogroup"}
|
||||
aria-label={question()?.question}
|
||||
inert={store.minimized}
|
||||
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
||||
classList={{ "pointer-events-none": hidden() > 0.1 }}
|
||||
style={{
|
||||
@@ -677,7 +671,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
picked={picked(opt.value)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
shortcut={i() < 9 ? String(i() + 1) : undefined}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
@@ -686,75 +679,89 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
data-editing={store.editing}
|
||||
data-disabled={sending()}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget || sending()) return
|
||||
setStore("focus", options().length)
|
||||
setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
customInput?.focus()
|
||||
}}
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description" dir="auto">
|
||||
{input() || customPlaceholder()}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-custom-toggle"
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
aria-label={customLabel()}
|
||||
aria-keyshortcuts={count() <= 9 ? String(count()) : undefined}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customToggle}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} />
|
||||
</button>
|
||||
<TextInput
|
||||
ref={(el) => (customInput = el)}
|
||||
class="question-custom-field"
|
||||
dir="auto"
|
||||
aria-label={customLabel()}
|
||||
placeholder={customLabel()}
|
||||
value={input()}
|
||||
disabled={sending()}
|
||||
onFocus={() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
focusFrame = undefined
|
||||
setStore("focus", options().length)
|
||||
setStore("editing", true)
|
||||
}}
|
||||
onClick={() => {
|
||||
setStore("customOn", store.tab, true)
|
||||
customUpdate(input(), true)
|
||||
}}
|
||||
onBlur={() => setStore("editing", false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
|
||||
if (e.key !== "Enter") return
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
setStore("customOn", store.tab, true)
|
||||
customUpdate(e.currentTarget.value, true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
dir="auto"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -74,6 +74,14 @@ export function createSessionScreenLayout(
|
||||
stacked: tabsOpen() && dockSideOpen(),
|
||||
}))
|
||||
|
||||
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
|
||||
createEffect((previous) => {
|
||||
const stacked = panelLayout().stacked
|
||||
|
||||
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
|
||||
|
||||
return stacked
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => tabsOpen() || fileTreeOpen())
|
||||
|
||||
const dockRegion = createMemo(() =>
|
||||
@@ -116,7 +124,10 @@ export function createSessionScreenLayout(
|
||||
},
|
||||
side: {
|
||||
contentWidth: sideContentWidth,
|
||||
gap: { height: createMemo(() => (panelLayout().stacked ? "8px" : "0px")) },
|
||||
gap: {
|
||||
closing: () => motion.closing,
|
||||
height: createMemo(() => (motion.gap ? "8px" : "0px")),
|
||||
},
|
||||
layout: panelLayout,
|
||||
region: {
|
||||
height: createMemo(() => {
|
||||
|
||||
@@ -172,9 +172,6 @@ function SessionScreenContent(props: {
|
||||
if (bottomDockVisible()) setStore("bottomDockCached", true)
|
||||
|
||||
if (!sideVisible()) setStore("sideHeightMotion", false)
|
||||
|
||||
// A side panel restored without its entrance animation never fires the animationend that enables height motion.
|
||||
if (sideVisible() && !sidePresence.animate()) setStore("sideHeightMotion", true)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!isDesktop() || screen.dock.bottom()) setStore("sideDockPresent", false)
|
||||
@@ -371,10 +368,11 @@ function SessionScreenContent(props: {
|
||||
<>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations. */}
|
||||
{/* Keep the control outside panel animations; a side dock's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
<div
|
||||
class="absolute end-3 top-0 z-30 h-12 flex items-center"
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideDockVisible(), "h-12": !sideDockVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
onPointerDown={hideTimelineScrollbar}
|
||||
onClick={hideTimelineScrollbar}
|
||||
@@ -489,13 +487,13 @@ function SessionScreenContent(props: {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="absolute start-0 bottom-0 flex flex-col" style={{ width: screen.side.contentWidth() }}>
|
||||
{/* The gap moves with both regions, so together they always fill the column and the review's
|
||||
bottom corners are never covered. */}
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !regionAnimating(),
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"delay-0": !screen.side.gap.closing(),
|
||||
"delay-[200ms]": screen.side.gap.closing(),
|
||||
"transition-none": !regionAnimating(),
|
||||
}}
|
||||
style={{ height: screen.side.gap.height() }}
|
||||
onPointerDown={() => screen.size.start()}
|
||||
|
||||
@@ -142,29 +142,6 @@ export const QuestionRequest = {
|
||||
),
|
||||
}
|
||||
|
||||
export const QuestionAnswerCounts = {
|
||||
render: () => (
|
||||
<SessionPreview
|
||||
title="Choose supporting surfaces"
|
||||
description={description}
|
||||
document={questionPendingDocument}
|
||||
request={{
|
||||
type: "question",
|
||||
value: {
|
||||
...activeQuestionRequest,
|
||||
fields: [
|
||||
activeQuestionRequest.fields[0],
|
||||
{
|
||||
...activeQuestionRequest.fields[1],
|
||||
options: [...activeQuestionRequest.fields[1].options, { value: "diagnostics", label: "Diagnostics" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const WebSearchRequest = {
|
||||
render: () => (
|
||||
<SessionPreview
|
||||
|
||||
@@ -180,7 +180,6 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
timelineDetail,
|
||||
pendingInputIDs,
|
||||
queuedCompactionIDs,
|
||||
turnDiffs: settings.general.showTurnDiffs,
|
||||
})
|
||||
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
|
||||
@@ -23,7 +23,6 @@ import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory } from "@opencode/util/path"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { parseVoiceDelegation } from "./voice-delegation"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
@@ -294,13 +293,11 @@ function MessageTimelineView(
|
||||
presentation: (message) => {
|
||||
const value = readPromptPresentation(message.metadata)
|
||||
const parsed = value ? undefined : parseCommentNote(message.text)
|
||||
const voice = parseVoiceDelegation(value?.displayText ?? message.text)
|
||||
|
||||
return {
|
||||
displayText: voice ? (voice.framing ?? voice.instruction) : value?.displayText,
|
||||
displayText: value?.displayText,
|
||||
comments: value?.comments ?? (parsed ? [parsed] : []),
|
||||
references: value?.attachments,
|
||||
voice: !!voice,
|
||||
}
|
||||
},
|
||||
actions: props.actions,
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("restores a view observed before its first attachment", async () => {
|
||||
const frame = openFrame()
|
||||
const targetWindow = frame.targetWindow
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const instance = attachedVirtualizer(viewport, { scrollOffset: 240, isScrollingResetDelay: 0 })
|
||||
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
scrollOffset: 240,
|
||||
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
|
||||
const connections: boolean[] = []
|
||||
|
||||
@@ -26,13 +32,12 @@ test("restores a view observed before its first attachment", async () => {
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
} finally {
|
||||
cleanup()
|
||||
frame.close()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const frame = openFrame()
|
||||
const targetWindow = frame.targetWindow
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
@@ -40,7 +45,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
route.append(viewport)
|
||||
targetWindow.document.body.append(route)
|
||||
|
||||
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 0 })
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 0,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
|
||||
const calls: [number, boolean][] = []
|
||||
|
||||
@@ -65,13 +80,12 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
frame.close()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
const frame = openFrame()
|
||||
const targetWindow = frame.targetWindow
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const animation = controlledAnimationFrames(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
@@ -79,7 +93,17 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
|
||||
route.append(viewport)
|
||||
targetWindow.document.body.append(route)
|
||||
|
||||
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 20 })
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 20,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
|
||||
const calls: number[] = []
|
||||
|
||||
@@ -103,39 +127,7 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
|
||||
expect(animation.pending()).toBe(0)
|
||||
} finally {
|
||||
cleanup?.()
|
||||
frame.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("settles on the live offset after a write whose scroll event is still pending", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
|
||||
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
|
||||
|
||||
const calls: [number, boolean][] = []
|
||||
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||
calls.push([offset, isScrolling])
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
try {
|
||||
viewport.scrollTop = 100
|
||||
viewport.dispatchEvent(new Event("scroll"))
|
||||
// A prepend anchor write lands before the reset delay; the browser has not dispatched its scroll event.
|
||||
viewport.scrollTop = 2_500
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
expect(calls).toEqual([
|
||||
[100, true],
|
||||
[2_500, false],
|
||||
])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,7 +137,17 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
|
||||
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 10,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
|
||||
const calls: [number, boolean][] = []
|
||||
|
||||
@@ -168,71 +170,49 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
|
||||
route.remove()
|
||||
})
|
||||
|
||||
// A real virtualizer attached to the viewport the way its mount does (scroll element plus the window that owns it),
|
||||
// without starting its own rect and offset observers, so each test drives the observer under test directly.
|
||||
function attachedVirtualizer(viewport: HTMLDivElement, state: { scrollOffset: number; isScrollingResetDelay: number }) {
|
||||
const instance = new Virtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: 0,
|
||||
getScrollElement: () => viewport,
|
||||
estimateSize: () => 0,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
isScrollingResetDelay: state.isScrollingResetDelay,
|
||||
})
|
||||
|
||||
instance.scrollElement = viewport
|
||||
instance.targetWindow = viewport.ownerDocument.defaultView
|
||||
instance.scrollOffset = state.scrollOffset
|
||||
|
||||
return instance
|
||||
type FrameWindow = {
|
||||
requestAnimationFrame(callback: () => void): unknown
|
||||
performance: { now(): number }
|
||||
}
|
||||
|
||||
// A separate window whose MutationObserver and animation frames a test can replace without touching the global
|
||||
// window. happy-dom loads a srcdoc frame synchronously, so its window is ready once the frame is attached.
|
||||
function openFrame() {
|
||||
const frame = document.createElement("iframe")
|
||||
frame.srcdoc = ""
|
||||
document.body.append(frame)
|
||||
const targetWindow = frame.contentDocument?.defaultView
|
||||
|
||||
if (!targetWindow) throw new Error("Frame window did not load")
|
||||
|
||||
return { targetWindow, close: () => frame.remove() }
|
||||
}
|
||||
|
||||
async function frames(count: number, targetWindow: Window = window) {
|
||||
async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => targetWindow.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
function controlledMutations(targetWindow: Window & typeof globalThis) {
|
||||
let flush: () => void = () => {
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
|
||||
// Delivers each operation's records as soon as it happens instead of at the next microtask.
|
||||
class ControlledMutationObserver extends targetWindow.MutationObserver {
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
super(callback)
|
||||
flush = () => callback(this.takeRecords(), this)
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
flush()
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
flush()
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,14 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
|
||||
onReconnect?: () => void,
|
||||
) {
|
||||
let active = true
|
||||
const element = instance.scrollElement
|
||||
|
||||
// TanStack resets isScrolling on a timer that reports the offset of the last scroll event. A virtualizer
|
||||
// write since then (prepend anchoring, size compensation) may not have dispatched its scroll event yet, and
|
||||
// compensating from that stale offset would undo the write, so a settled scroll reads the live scrollTop
|
||||
// (vertical only, like the reconnect check below).
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
if (!active) return
|
||||
callback(isScrolling || !element?.isConnected ? offset : element.scrollTop, isScrolling)
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
|
||||
const cleanupOffset = observeElementOffset(instance, deliver)
|
||||
const element = instance.scrollElement
|
||||
const targetWindow = instance.targetWindow
|
||||
const root = element?.closest("main") ?? element?.ownerDocument.body
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createTurnDiffCache } from "./turn-diffs"
|
||||
|
||||
const turn = { sessionID: "ses_a", from: "msg_open" }
|
||||
|
||||
// Every pending promise callback runs before the next macrotask.
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
const diff = { file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "modified" } satisfies FileDiffInfo
|
||||
|
||||
describe("turn diff cache", () => {
|
||||
test("loads each turn once and retries after a failure", async () => {
|
||||
const cache = createRoot(() => createTurnDiffCache())
|
||||
const failed = Promise.withResolvers<readonly FileDiffInfo[]>()
|
||||
const calls: string[] = []
|
||||
|
||||
cache.load(turn, () => (calls.push("failed"), failed.promise))
|
||||
cache.load(turn, () => (calls.push("duplicate"), Promise.resolve([diff])))
|
||||
failed.reject(new Error("offline"))
|
||||
await settle()
|
||||
expect(cache.get(turn)).toBeUndefined()
|
||||
|
||||
cache.load(turn, () => (calls.push("retry"), Promise.resolve([diff])))
|
||||
await settle()
|
||||
cache.load(turn, () => (calls.push("cached"), Promise.resolve([])))
|
||||
|
||||
expect(calls).toEqual(["failed", "retry"])
|
||||
expect(cache.get(turn)).toEqual([diff])
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { TurnDiffInput } from "@opencode/session-ui/context"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
/**
|
||||
* One tab's in-memory cache of the files each finished turn changed. A finished turn's snapshots never
|
||||
* change, so each turn loads once per tab and survives the timeline remounting; closing the tab drops it.
|
||||
*/
|
||||
export function createTurnDiffCache() {
|
||||
const [diffs, setDiffs] = createStore<Record<string, readonly FileDiffInfo[] | undefined>>({})
|
||||
const loading = new Set<string>()
|
||||
const key = (input: TurnDiffInput) => `${input.sessionID}/${input.from}`
|
||||
|
||||
return {
|
||||
get: (input: TurnDiffInput) => diffs[key(input)],
|
||||
load(input: TurnDiffInput, fetch: () => Promise<readonly FileDiffInfo[]>) {
|
||||
const id = key(input)
|
||||
|
||||
if (diffs[id] || loading.has(id)) return
|
||||
loading.add(id)
|
||||
// A failed load leaves the turn unloaded, so the next request retries.
|
||||
void fetch()
|
||||
.then(
|
||||
(value) => setDiffs(id, value),
|
||||
() => undefined,
|
||||
)
|
||||
.finally(() => loading.delete(id))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseVoiceDelegation } from "./voice-delegation"
|
||||
|
||||
// Prompts as the OpenCode iOS app has sent them (`VoiceHandoff.format`), oldest first.
|
||||
const firstRelease = `Voice delegation. Instruction is the cleaned interpretation of the user's request. Transcript delta supplies new or revised conversation for reference, not additional commands. Resolve meaning from the complete utterance, not a disfluent prefix. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail.
|
||||
{
|
||||
"instruction" : "Check why the upload test is flaky.",
|
||||
"transcript_delta" : [
|
||||
"user: the upload test",
|
||||
"assistant: On it."
|
||||
]
|
||||
}`
|
||||
|
||||
const current = `Voice delegation. The user is on a voice call with a voice agent, which hands their requests to you. Instruction is the user's own spoken words since the previous delegation.
|
||||
{
|
||||
"context" : "Session \\"Uploads\\" in project \\"app\\".",
|
||||
"instruction" : "check the upload test",
|
||||
"transcript_delta" : [
|
||||
"user: check the upload test"
|
||||
],
|
||||
"voice" : "Checking the upload test."
|
||||
}`
|
||||
|
||||
const continued = `Voice delegation (continued). Same fields as before.
|
||||
{
|
||||
"instruction" : "and open a PR",
|
||||
"transcript_delta" : [
|
||||
|
||||
]
|
||||
}`
|
||||
|
||||
describe("parseVoiceDelegation", () => {
|
||||
test.each([
|
||||
["the first release's header", firstRelease, { instruction: "Check why the upload test is flaky." }],
|
||||
[
|
||||
"the current header with framing",
|
||||
current,
|
||||
{ instruction: "check the upload test", framing: "Checking the upload test." },
|
||||
],
|
||||
["a continued delegation", continued, { instruction: "and open a PR" }],
|
||||
[
|
||||
"a call log attached as a reference line",
|
||||
`${continued}\n\nAttached file: \`/tmp/uploads/1/voice-call-a.json\``,
|
||||
{ instruction: "and open a PR" },
|
||||
],
|
||||
])("reads %s", (_, text, expected) => {
|
||||
expect(parseVoiceDelegation(text)).toEqual({ framing: undefined, ...expected })
|
||||
})
|
||||
|
||||
test("ignores blank framing", () => {
|
||||
expect(parseVoiceDelegation(current.replace('"Checking the upload test."', '" "'))?.framing).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each([
|
||||
["a typed message that mentions one", "What does a Voice delegation. prompt look like?"],
|
||||
["a header without a body", "Voice delegation. Same fields as before."],
|
||||
["a body that isn't JSON", "Voice delegation. Header\ncheck the upload test"],
|
||||
["a blank instruction", 'Voice delegation. Header\n{"instruction": " ", "transcript_delta": []}'],
|
||||
["a body without an instruction", 'Voice delegation. Header\n{"transcript_delta": []}'],
|
||||
])("rejects %s", (_, text) => {
|
||||
expect(parseVoiceDelegation(text)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
// The OpenCode iOS app sends each request from a voice call as plain prompt text: a header line, then a JSON
|
||||
// body. Nothing else marks the message, so this mirrors the app's `VoiceHandoff.parse`. The header's wording
|
||||
// has changed between releases; its opening and the body's shape have not.
|
||||
const decodeBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
instruction: Schema.String,
|
||||
voice: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** The user's spoken request and the voice agent's framing of it, or undefined for any other prompt. */
|
||||
export function parseVoiceDelegation(text: string) {
|
||||
if (!text.startsWith("Voice delegation.") && !text.startsWith("Voice delegation (continued).")) return
|
||||
|
||||
const newline = text.indexOf("\n")
|
||||
|
||||
if (newline === -1) return
|
||||
|
||||
// Without presentation metadata, path attachments such as the call log follow the body as reference lines.
|
||||
const body = Option.getOrUndefined(
|
||||
decodeBody(text.slice(newline + 1).replace(/(\s*\nAttached file: `[^\n]*`)+\s*$/, "")),
|
||||
)
|
||||
|
||||
const instruction = body?.instruction.trim()
|
||||
|
||||
if (!instruction) return
|
||||
|
||||
return { instruction, framing: body?.voice?.trim() || undefined }
|
||||
}
|
||||
@@ -317,17 +317,6 @@ export const SettingsGeneral: Component = () => {
|
||||
onChange={settings.general.setTimelineDetail}
|
||||
/>
|
||||
</div>
|
||||
<SettingsRow
|
||||
title={language.t("settings.timeline.row.turnDiffs.title")}
|
||||
description={language.t("settings.timeline.row.turnDiffs.description")}
|
||||
>
|
||||
<div data-action="settings-show-turn-diffs">
|
||||
<Switch
|
||||
checked={settings.general.showTurnDiffs()}
|
||||
onChange={(checked) => settings.general.setShowTurnDiffs(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ describe("settings schema", () => {
|
||||
showFileTree: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
|
||||
@@ -116,7 +116,6 @@ const generalSchema = Persistence.struct({
|
||||
tools: placementOnlySchema,
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
showTurnDiffs: Schema.Boolean,
|
||||
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
|
||||
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
|
||||
mobileDiffWrap: Persistence.optional(Schema.Boolean),
|
||||
@@ -279,7 +278,6 @@ export const defaultSettings: Settings = {
|
||||
showFileTree: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
@@ -353,10 +351,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
showTurnDiffs: withFallback(() => store.general?.showTurnDiffs, defaultSettings.general.showTurnDiffs),
|
||||
setShowTurnDiffs(value: boolean) {
|
||||
setStore("general", "showTurnDiffs", value)
|
||||
},
|
||||
mobileTitlebarPosition: withFallback(
|
||||
() => store.general?.mobileTitlebarPosition,
|
||||
defaultSettings.general.mobileTitlebarPosition,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { createEffect, ErrorBoundary, For, on, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { SettingsSearch } from "./search"
|
||||
import "./search.css"
|
||||
@@ -143,18 +142,7 @@ export function SettingsNavigation(props: {
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="settings-content">
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => {
|
||||
// Keep a failed page inside the content area; choosing another page renders it again.
|
||||
createEffect(on(() => props.value, reset, { defer: true }))
|
||||
|
||||
return <ErrorPage error={error} />
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div class="settings-content">{props.children}</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { consoleProviderGroup } from "@/providers/catalog/console"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { CONSOLE_INTEGRATION } from "@/providers/connect/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { activeProviderAccount, type ProviderAccount } from "./accounts"
|
||||
|
||||
// Shared by the connect dialog and Settings so both switch, rename and remove accounts the same way.
|
||||
export function useProviderAccountActions(directory: () => string | undefined) {
|
||||
const language = useLanguage()
|
||||
const sdk = useServerSDK()
|
||||
const data = useData()
|
||||
const providers = useProviders(directory)
|
||||
const integrations = useIntegrations(directory)
|
||||
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
data.location.model.invalidate(ref)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(ref),
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
])
|
||||
}
|
||||
|
||||
// The server loads a Console workspace's providers after the credential changes, so the first
|
||||
// refresh can still show only the free catalog. Poll briefly before reporting the new account.
|
||||
// A Zen API key shares the Console integration but has no workspace to wait for.
|
||||
const settle = async (integrationID: string) => {
|
||||
const account = activeProviderAccount(integrations.list().find((integration) => integration.id === integrationID))
|
||||
|
||||
if (integrationID !== CONSOLE_INTEGRATION || account?.method !== "oauth") return
|
||||
const deadline = Date.now() + 10_000
|
||||
|
||||
while (!consoleProviderGroup(providers.connected()) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
data.location.provider.invalidate(location())
|
||||
data.location.model.invalidate(location())
|
||||
await Promise.all([data.location.provider.sync(location()), data.location.model.sync(location())])
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (message: string) => showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
|
||||
const activate = (input: { integrationID: string; provider: string; account: ProviderAccount }) =>
|
||||
sdk.api.credential
|
||||
.activate({ credentialID: input.account.id })
|
||||
.then(refresh)
|
||||
.then(() => settle(input.integrationID))
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.providers.account.switched.title", { provider: input.provider }),
|
||||
description: language.t("settings.providers.account.switched.description", { account: input.account.label }),
|
||||
}),
|
||||
)
|
||||
.catch((error) => fail(formatServerError(error, language.t)))
|
||||
|
||||
const remove = (input: { integrationID: string; provider: string; account: ProviderAccount; final: boolean }) =>
|
||||
sdk.api.credential
|
||||
.remove({ credentialID: input.account.id })
|
||||
.then(refresh)
|
||||
.then(() => (input.final ? undefined : settle(input.integrationID)))
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: input.final
|
||||
? language.t("provider.disconnect.toast.disconnected.title", { provider: input.provider })
|
||||
: language.t("settings.providers.account.removed.title", { account: input.account.label }),
|
||||
description: input.final
|
||||
? language.t("provider.disconnect.toast.disconnected.description", { provider: input.provider })
|
||||
: language.t("settings.providers.account.removed.description", { provider: input.provider }),
|
||||
}),
|
||||
)
|
||||
.catch((error) => fail(formatServerError(error, language.t)))
|
||||
|
||||
const rename = (account: ProviderAccount, label: string) =>
|
||||
sdk.api.credential
|
||||
.update({ credentialID: account.id, label })
|
||||
.then(refresh)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
fail(formatServerError(error, language.t))
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return { refresh, activate, remove, rename }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function AccountNameInput(props: {
|
||||
value: string
|
||||
class?: string
|
||||
onSave: (label: string) => Promise<boolean>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ draft: props.value, saving: false, cancelled: false })
|
||||
let input: HTMLInputElement | undefined
|
||||
|
||||
// Waits a frame so a closing menu's focus restore cannot pull focus back out of the field.
|
||||
onMount(() =>
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus()
|
||||
input?.select()
|
||||
}),
|
||||
)
|
||||
|
||||
const save = async () => {
|
||||
const label = store.draft.trim()
|
||||
|
||||
if (!label || label === props.value) return props.onClose()
|
||||
setStore("saving", true)
|
||||
|
||||
if (await props.onSave(label)) return props.onClose()
|
||||
setStore("saving", false)
|
||||
requestAnimationFrame(() => input?.focus())
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineInput
|
||||
ref={input}
|
||||
aria-label={language.t("settings.providers.account.name")}
|
||||
dir="auto"
|
||||
value={store.draft}
|
||||
disabled={store.saving}
|
||||
class={`field-sizing-content rounded-[6px] px-1 py-1 ${props.class ?? ""}`}
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => setStore("draft", event.currentTarget.value)}
|
||||
data-owns-escape
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void save()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
setStore("cancelled", true)
|
||||
props.onClose()
|
||||
}}
|
||||
// Like session title editing, clicking away keeps the typed name.
|
||||
onBlur={() => {
|
||||
if (!store.saving && !store.cancelled) void save()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -29,14 +29,9 @@ export const SettingsExtensions: Component<{
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
|
||||
// Reading `.latest` rethrows a failed fetch, so these lists fall back to empty instead of crashing the page.
|
||||
const [mcpList, { refetch: refetchMcp }] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() =>
|
||||
serverSdk.api.mcp
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
() => serverSdk.api.mcp.list().then((result) => result.data),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
@@ -56,11 +51,7 @@ export const SettingsExtensions: Component<{
|
||||
|
||||
const [pluginList] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() =>
|
||||
serverSdk.api.plugin
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -13,14 +12,12 @@ import { createEffect, createMemo, type Component, For, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/controller"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
|
||||
import { AccountNameInput } from "./account-name"
|
||||
import { useProviderAccountActions } from "./account-actions"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
|
||||
@@ -45,20 +42,16 @@ export const SettingsProviders: Component<{
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController()
|
||||
const accountActions = useProviderAccountActions(() => props.directory)
|
||||
|
||||
const [state, setState] = createStore<{
|
||||
disconnecting: Record<string, "removing" | "removed" | "absent" | undefined>
|
||||
consoleExpanded: boolean
|
||||
credentialID?: string
|
||||
renaming?: string
|
||||
held?: ProviderItem[]
|
||||
}>({
|
||||
disconnecting: {},
|
||||
const [state, setState] = createStore({
|
||||
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
|
||||
consoleExpanded: false,
|
||||
connecting: false,
|
||||
credentialID: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
|
||||
@@ -76,9 +69,7 @@ export const SettingsProviders: Component<{
|
||||
}
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
// The list stays as it was while the dialog connects, so a new sign-in that briefly drops the
|
||||
// Console workspace providers from the catalog can't flash rows behind the dialog.
|
||||
setState("held", available())
|
||||
setState("connecting", true)
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(
|
||||
() => (
|
||||
@@ -97,15 +88,22 @@ export const SettingsProviders: Component<{
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => void accountActions
|
||||
.refresh()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setState("held", undefined)),
|
||||
() => {
|
||||
setState("connecting", false)
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
void Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
]).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const available = createMemo(() => {
|
||||
if (state.held) return state.held
|
||||
const connected = providers.connected()
|
||||
const managedConsole = consoleProviderGroup(connected)
|
||||
|
||||
@@ -114,13 +112,21 @@ export const SettingsProviders: Component<{
|
||||
.find((item) => item.id === CONSOLE_INTEGRATION)
|
||||
?.connections.some((connection) => connection.type === "credential" || connection.type === "env")
|
||||
|
||||
// Hides the free `opencode` row while a new Console grant is still loading its workspace providers.
|
||||
const consoleTransition =
|
||||
state.connecting &&
|
||||
CONSOLE_PROVIDERS.has(providerConnect.selected() ?? "") &&
|
||||
consoleConnected &&
|
||||
managedConsole === undefined
|
||||
|
||||
return connected
|
||||
.filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" ||
|
||||
managedConsole !== undefined ||
|
||||
consoleConnected ||
|
||||
Object.values(provider.models).some((model) => model.cost.input > 0),
|
||||
(!consoleTransition &&
|
||||
(managedConsole !== undefined ||
|
||||
consoleConnected ||
|
||||
Object.values(provider.models).some((model) => model.cost.input > 0))),
|
||||
)
|
||||
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
|
||||
})
|
||||
@@ -254,9 +260,9 @@ export const SettingsProviders: Component<{
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: unknown) => {
|
||||
updateDisconnecting(ids, undefined)
|
||||
const message = formatServerError(err, language.t)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.tDynamic("provider.disconnect.toast.failed.description", message, { provider: name }),
|
||||
@@ -264,129 +270,130 @@ export const SettingsProviders: Component<{
|
||||
})
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
])
|
||||
}
|
||||
|
||||
const accountError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
if (activeProviderAccount(integration(provider))?.id === account.id) return
|
||||
setState({ credentialID: account.id, held: available() })
|
||||
await accountActions.activate({ integrationID: provider.integrationID ?? provider.id, provider: providerName, account })
|
||||
setState({ credentialID: undefined, held: undefined })
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.activate({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
|
||||
description: language.t("settings.providers.account.switched.description", { account: account.label }),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
setState({ credentialID: account.id, held: available() })
|
||||
await accountActions.remove({
|
||||
integrationID: provider.integrationID ?? provider.id,
|
||||
provider: providerName,
|
||||
account,
|
||||
final: providerAccounts(integration(provider)).length === 1,
|
||||
})
|
||||
setState({ credentialID: undefined, held: undefined })
|
||||
const final = providerAccounts(integration(provider)).length === 1
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.remove({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t(
|
||||
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
|
||||
final ? { provider: providerName } : { account: account.label },
|
||||
),
|
||||
description: language.t(
|
||||
final
|
||||
? "provider.disconnect.toast.disconnected.description"
|
||||
: "settings.providers.account.removed.description",
|
||||
{ provider: providerName },
|
||||
),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
|
||||
const accounts = () => providerAccounts(integration(menuProps.provider))
|
||||
const active = () => activeProviderAccount(integration(menuProps.provider))
|
||||
const name = () => menuProps.name ?? menuProps.provider.name
|
||||
const renaming = () => accounts().find((account) => account.id === state.renaming)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={renaming()}
|
||||
fallback={
|
||||
<AccountMenuTrigger provider={menuProps.provider} name={name()} accounts={accounts()} active={active()} />
|
||||
}
|
||||
>
|
||||
{(account) => (
|
||||
<AccountNameInput
|
||||
value={account().label}
|
||||
class="settings-provider-account-input"
|
||||
onSave={(label) => accountActions.rename(account(), label)}
|
||||
onClose={() => setState("renaming", undefined)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
class="settings-provider-account-trigger"
|
||||
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
|
||||
>
|
||||
<span>{active()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
class="settings-provider-account-list"
|
||||
value={active()?.id}
|
||||
onChange={(credentialID) => {
|
||||
const account = accounts().find((item) => item.id === credentialID)
|
||||
|
||||
function AccountMenuTrigger(menuProps: {
|
||||
provider: ProviderItem
|
||||
name: string
|
||||
accounts: ProviderAccount[]
|
||||
active: ProviderAccount | undefined
|
||||
}) {
|
||||
const accounts = () => menuProps.accounts
|
||||
const active = () => menuProps.active
|
||||
const name = () => menuProps.name
|
||||
|
||||
return (
|
||||
<div class="settings-provider-account-actions">
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
class="settings-provider-account-trigger"
|
||||
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
|
||||
>
|
||||
<span>{active()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
class="settings-provider-account-list"
|
||||
value={active()?.id}
|
||||
onChange={(credentialID) => {
|
||||
const account = accounts().find((item) => item.id === credentialID)
|
||||
|
||||
if (account) void activate(menuProps.provider, name(), account)
|
||||
}}
|
||||
>
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
|
||||
<Icon name="plus" size="small" class="shrink-0" />
|
||||
{language.t("settings.providers.account.add")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<Icon name="outline-dots" />}
|
||||
disabled={state.credentialID !== undefined || !active()}
|
||||
aria-label={language.t("dialog.provider.accounts.options", { account: active()?.label ?? "" })}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
|
||||
<Menu.Item onSelect={() => setState("renaming", active()?.id)}>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
const account = active()
|
||||
|
||||
if (account) void remove(menuProps.provider, name(), account)
|
||||
if (account) void activate(menuProps.provider, name(), account)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.provider.accounts.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
|
||||
{language.t("settings.providers.account.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Sub placement="left-start">
|
||||
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
|
||||
{language.t("settings.providers.account.remove")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.SubContent class="settings-provider-account-submenu">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.Item
|
||||
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
|
||||
onSelect={() => void remove(menuProps.provider, name(), account)}
|
||||
>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Sub>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1037,15 +1037,9 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-provider-account-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: min(272px, 50%);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger {
|
||||
min-width: 0;
|
||||
max-width: min(240px, 45%);
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger > span {
|
||||
@@ -1055,14 +1049,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-provider-account-input {
|
||||
max-width: min(240px, 45%);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-provider-account-menu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
overflow: visible;
|
||||
@@ -1073,6 +1059,12 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-submenu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
max-height: min(360px, var(--kb-popper-content-available-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1081,7 +1073,7 @@
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
.settings-provider-account-actions {
|
||||
.settings-provider-account-trigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1313,11 +1305,7 @@
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-model-group[data-expanded]:not(:last-child) {
|
||||
margin-block-end: 4px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-models-console-groups {
|
||||
@@ -1335,11 +1323,9 @@
|
||||
}
|
||||
|
||||
.provider-model-group-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.provider-model-group-trigger {
|
||||
@@ -1363,12 +1349,8 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.provider-model-group-header:has(:focus-visible) {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.provider-model-group-header:hover {
|
||||
.provider-model-group-trigger:not(:disabled):hover {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
}
|
||||
@@ -1411,7 +1393,7 @@
|
||||
|
||||
.provider-model-group-actions {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
@@ -1431,24 +1413,24 @@
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-header :focus-visible)
|
||||
.provider-model-group:has(.provider-model-group-trigger:focus-visible)
|
||||
.provider-model-group-models
|
||||
> [data-component="settings-list"] {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-header :focus-visible) {
|
||||
.provider-model-group:has(.provider-model-group-trigger:focus-visible) {
|
||||
outline: 0.5px solid var(--v2-border-border-muted);
|
||||
outline-offset: -0.5px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.provider-model-group:has(.provider-model-group-header:hover) {
|
||||
.provider-model-group:has(.provider-model-group-trigger:hover) {
|
||||
outline: 0.5px solid var(--v2-border-border-muted);
|
||||
outline-offset: -0.5px;
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-header:hover)
|
||||
.provider-model-group:has(.provider-model-group-trigger:hover)
|
||||
.provider-model-group-models
|
||||
> [data-component="settings-list"] {
|
||||
border-top-color: transparent;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { pluginLabels } from "@/providers/catalog/plugin"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { ProjectFolderMissing } from "./project-folder-missing"
|
||||
import { configuredLanguageServers } from "./project-lsp"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "./project.css"
|
||||
@@ -249,25 +248,15 @@ export const ProjectSettingsExtensions: Component<{
|
||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
|
||||
?.status.status === "connected"
|
||||
|
||||
// Reading `.latest` rethrows a failed fetch, such as the project's plugin list after its folder is deleted, so these
|
||||
// lists fall back to empty instead of crashing the page.
|
||||
const [globalPluginList] = createResource(
|
||||
() => serverSDK.connection.status() === "connected",
|
||||
() =>
|
||||
serverSDK.api.plugin
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const [projectPluginList] = createResource(
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) =>
|
||||
serverSDK.api.plugin
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
@@ -329,7 +318,6 @@ export const ProjectSettingsExtensions: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<ProjectFolderMissing />
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.subtab ?? "mcps"}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import "./project.css"
|
||||
|
||||
export function ProjectFolderMissing() {
|
||||
const language = useLanguage()
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
return (
|
||||
<Show when={location().missing}>
|
||||
{(missing) => (
|
||||
<div role="status" class="project-settings-folder-missing">
|
||||
<Icon name="warning" class="project-settings-folder-missing-icon" />
|
||||
<div class="project-settings-folder-missing-copy">
|
||||
<span class="project-settings-folder-missing-title">
|
||||
{language.t("project.settings.folderMissing.title")}
|
||||
</span>
|
||||
<bdi dir="ltr" class="project-settings-folder-missing-path">
|
||||
{missing().directory}
|
||||
</bdi>
|
||||
<span>{language.t("project.settings.folderMissing.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -94,43 +94,6 @@
|
||||
background: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-path {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-settings-shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { useServerCtx } from "@/runtime/server/runtime"
|
||||
import { projectSettingsKey, useSettings } from "@/settings/model"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { ProjectFolderMissing } from "./project-folder-missing"
|
||||
import { ProjectOptions } from "./project-options"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -64,7 +63,6 @@ export const SettingsProjectGeneral: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
|
||||
<ProjectFolderMissing />
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("project.settings.name.title")}
|
||||
|
||||
@@ -8,11 +8,10 @@ import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs, type Tab } from "@/shell/tabs/tabs"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createTurnDiffCache } from "@/session/timeline/turn-diffs"
|
||||
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
@@ -66,23 +65,6 @@ export function SessionUIProvider(
|
||||
|
||||
const providers = useProviders(directory)
|
||||
|
||||
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
|
||||
const turnDiffs = () => {
|
||||
const id = params.id
|
||||
|
||||
if (!id) return
|
||||
|
||||
const tab =
|
||||
tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === props.server &&
|
||||
(item.sessionId === id || item.routeSessionId === id),
|
||||
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
|
||||
|
||||
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
|
||||
}
|
||||
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
|
||||
@@ -106,10 +88,6 @@ export function SessionUIProvider(
|
||||
sessionID={params.id}
|
||||
shellRunning={(id) => !!data.shell.get(id)}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
turnDiff={{
|
||||
get: (input) => turnDiffs()?.get(input),
|
||||
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
|
||||
}}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
||||
@@ -9,45 +9,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] :is([data-slot="tab-unread-dot"], [data-slot="tab-avatar-unread-dot"]) {
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="project-avatar-slot"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-avatar-unread-dot"] {
|
||||
display: none;
|
||||
inset-inline-end: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-unread-slot"] {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
display: flex;
|
||||
margin-block: auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-titlebar-tab]:not(:where([data-slot="mobile-tabs-drawer"] *)):is(:hover, :has(:focus-visible))
|
||||
[data-slot="tab-unread-slot"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-titlebar-tab],
|
||||
[data-titlebar-tab-action] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
@@ -116,8 +77,7 @@
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-state="pressed"], [data-unread="true"])
|
||||
[data-slot="tab-close"] {
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
@@ -137,31 +97,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Each link owns half the gap, without expanding its painted background or close button. */
|
||||
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab] {
|
||||
--tab-hit-before: 0px;
|
||||
--tab-hit-after: 0px;
|
||||
--tab-link-inset: 6px;
|
||||
overflow: visible;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-link] {
|
||||
align-self: flex-start;
|
||||
height: calc(100% + var(--tab-hit-before) + var(--tab-hit-after));
|
||||
margin-block-start: calc(-1 * var(--tab-hit-before));
|
||||
padding-block: var(--tab-hit-before) var(--tab-hit-after);
|
||||
padding-inline: var(--tab-link-inset);
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-slot] + [data-titlebar-tab-slot] [data-titlebar-tab] {
|
||||
--tab-hit-before: 2px;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-slot]:has(+ [data-titlebar-tab-slot]) [data-titlebar-tab] {
|
||||
--tab-hit-after: 2px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
--tab-separator: var(--v2-background-bg-layer-03);
|
||||
position: relative;
|
||||
@@ -197,11 +132,8 @@
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
|
||||
[data-editing="true"]
|
||||
)
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: calc(4px + var(--tab-link-inset, 0px));
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 4px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0,
|
||||
@@ -219,10 +151,7 @@
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
|
||||
[data-editing="true"]
|
||||
):dir(rtl)
|
||||
[data-slot="tab-link"] {
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
black 0,
|
||||
@@ -239,16 +168,13 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
|
||||
[data-editing="true"]
|
||||
)
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: calc(24px + var(--tab-link-inset, 0px));
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
padding-inline-end: var(--tab-link-inset, 0px);
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||
@@ -270,17 +196,13 @@
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-titlebar-tab] [data-slot="tab-unread-slot"] {
|
||||
inset-inline-end: 20px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: calc(24px + var(--tab-link-inset, 0px));
|
||||
--tab-title-fade-offset: 24px;
|
||||
--tab-title-fade-direction: to right;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
var(--tab-title-fade-direction),
|
||||
@@ -301,53 +223,22 @@
|
||||
[data-titlebar-tab]:dir(rtl) [data-slot="tab-link"] {
|
||||
--tab-title-fade-direction: to left;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-unread="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: calc(44px + var(--tab-link-inset, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@container (44px < width <= 64px) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(
|
||||
:hover,
|
||||
:has(:focus-visible),
|
||||
[data-active="true"],
|
||||
[data-unread="true"]
|
||||
)
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
|
||||
[data-slot="tab-link"] {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(
|
||||
:hover,
|
||||
:has(:focus-visible),
|
||||
[data-active="true"],
|
||||
[data-unread="true"]
|
||||
)
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
|
||||
[data-titlebar-tab-title] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 44px) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-avatar-unread-dot"] {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-unread="true"]:not([data-orientation="vertical"]) [data-slot="project-avatar-surface"] {
|
||||
-webkit-mask-image: radial-gradient(circle 4.5px at 100% 0, transparent 4px, black 5px);
|
||||
mask-image: radial-gradient(circle 4.5px at 100% 0, transparent 4px, black 5px);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-unread="true"]:not([data-orientation="vertical"]):dir(rtl)
|
||||
[data-slot="project-avatar-surface"] {
|
||||
-webkit-mask-image: radial-gradient(circle 4.5px at 0 0, transparent 4px, black 5px);
|
||||
mask-image: radial-gradient(circle 4.5px at 0 0, transparent 4px, black 5px);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
@@ -27,8 +27,6 @@ export function TabNavItem(props: {
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -58,8 +56,6 @@ export function TabNavItem(props: {
|
||||
const servers = useServers()
|
||||
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
||||
|
||||
const unread = () => !!props.session && !props.active && props.unread
|
||||
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
@@ -236,7 +232,6 @@ export function TabNavItem(props: {
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-unread={unread()}
|
||||
data-dragging={props.dragging}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
onMouseDown={(event) => {
|
||||
@@ -303,17 +298,14 @@ export function TabNavItem(props: {
|
||||
}
|
||||
>
|
||||
{(session) => (
|
||||
<SessionTabAvatarView
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session.location.directory}
|
||||
unread={false}
|
||||
loading={props.loading}
|
||||
sessionId={session.id}
|
||||
server={props.server}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={unread()}>
|
||||
<span data-slot="tab-avatar-unread-dot" aria-hidden="true" />
|
||||
</Show>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
@@ -358,15 +350,10 @@ export function TabNavItem(props: {
|
||||
</Menu.Context.Trigger>
|
||||
|
||||
<div data-slot="tab-close">
|
||||
<Show when={unread()}>
|
||||
<span data-slot="tab-unread-slot" aria-hidden="true">
|
||||
<span data-slot="tab-unread-dot" />
|
||||
</span>
|
||||
</Show>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
|
||||
test("finds the nearest accepted tab in either direction, wrapping around", () => {
|
||||
const order = ["a", "b", "c", "d", "e"]
|
||||
const unread = new Set(["a", "d"])
|
||||
|
||||
expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined()
|
||||
})
|
||||
import { mergeVisibleTabOrder } from "./tab-order"
|
||||
|
||||
test("merges reordered visible tabs around hidden tabs", () => {
|
||||
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */
|
||||
export function adjacentTabKey(
|
||||
order: string[],
|
||||
current: string | undefined,
|
||||
offset: -1 | 1,
|
||||
include: (key: string) => boolean = () => true,
|
||||
) {
|
||||
if (!current) return
|
||||
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
||||
if (!current || order.length === 0) return
|
||||
const index = order.indexOf(current)
|
||||
|
||||
if (index === -1) return
|
||||
|
||||
return Array.from(
|
||||
{ length: order.length - 1 },
|
||||
(_, step) => order[(index + offset * (step + 1) + order.length) % order.length],
|
||||
).find(include)
|
||||
return order[(index + offset + order.length) % order.length]
|
||||
}
|
||||
|
||||
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
color: var(--v2-text-text-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
/* Kobalte's dismissable layer writes an inline pointer-events value. */
|
||||
pointer-events: none !important;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||
/* Entrance: instant (trying no animation — may revert to 120ms ease-out). */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HoverCard } from "@kobalte/core/hover-card"
|
||||
import { createEffect, createSignal, onCleanup, Show, type JSXElement } from "solid-js"
|
||||
import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import "./tab-popover.css"
|
||||
@@ -7,6 +7,7 @@ import "./tab-popover.css"
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 750
|
||||
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
@@ -16,8 +17,6 @@ const SKIP_WINDOW = 500
|
||||
|
||||
let lastClosedAt = 0
|
||||
|
||||
let activePreview: (() => void) | undefined
|
||||
|
||||
export interface TabPreviewData {
|
||||
projectName?: string
|
||||
title?: string
|
||||
@@ -40,34 +39,14 @@ export function TabPreviewPopover(props: {
|
||||
// "cold" preview animates. Mirrors how browsers reuse one tab tooltip.
|
||||
const [instant, setInstant] = createSignal(false)
|
||||
|
||||
const warm = () => !!activePreview || Date.now() - lastClosedAt < SKIP_WINDOW
|
||||
const open = () => props.open && !mobile()
|
||||
const close = () => props.onOpenChange(false)
|
||||
|
||||
// Observe accepted, controlled state, including drag/menu suppression in TabNav.
|
||||
createEffect(() => {
|
||||
if (open()) {
|
||||
if (activePreview !== close) activePreview?.()
|
||||
activePreview = close
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (activePreview !== close) return
|
||||
activePreview = undefined
|
||||
lastClosedAt = Date.now()
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (activePreview !== close) return
|
||||
activePreview = undefined
|
||||
lastClosedAt = Date.now()
|
||||
})
|
||||
const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW
|
||||
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||
// resolves the skip window per-hover.
|
||||
const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY)
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) setInstant(warm())
|
||||
else lastClosedAt = Date.now()
|
||||
props.onOpenChange(open)
|
||||
}
|
||||
|
||||
@@ -78,11 +57,12 @@ export function TabPreviewPopover(props: {
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
open={open()}
|
||||
open={props.open && !mobile()}
|
||||
onOpenChange={handleOpenChange}
|
||||
openDelay={resolveOpenDelay()}
|
||||
closeDelay={CLOSE_DELAY}
|
||||
// Informational previews belong to their tab, not to a hoverable safe area.
|
||||
// The preview is non-interactive (pointer-events: none), so there is no
|
||||
// safe area to traverse — leaving the tab hides it immediately.
|
||||
ignoreSafeArea
|
||||
placement={placement()}
|
||||
gutter={6}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -28,8 +27,6 @@ function SessionTabSlot(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -67,8 +64,6 @@ function SessionTabSlot(props: {
|
||||
server={props.tab.server}
|
||||
session={props.session}
|
||||
preparing={props.preparing}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
fallbackTitle={props.fallbackTitle}
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
@@ -89,7 +84,6 @@ function SessionTabEntry(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onUnreadChange: (unread: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
@@ -100,15 +94,6 @@ function SessionTabEntry(props: {
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
|
||||
const status = useSessionTabAvatarState(
|
||||
() => props.tab.server,
|
||||
() => props.tab.sessionId,
|
||||
() => true,
|
||||
)
|
||||
|
||||
// A busy session shows its progress indicator instead of the unread dot.
|
||||
const unread = createMemo(() => status.unread() && !status.loading())
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (pending()) return null
|
||||
@@ -150,7 +135,6 @@ function SessionTabEntry(props: {
|
||||
}
|
||||
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
createEffect(() => props.onUnreadChange(unread()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
@@ -202,8 +186,6 @@ function SessionTabEntry(props: {
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
preparing={!!pending()}
|
||||
unread={unread()}
|
||||
loading={status.loading()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("session.tab.session")
|
||||
@@ -281,7 +263,6 @@ export function TitlebarTabStrip(props: {
|
||||
const vertical = () => props.orientation === "vertical"
|
||||
let listRef!: HTMLDivElement
|
||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||
const [unread, setUnread] = createStore<Record<string, boolean>>({})
|
||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||
|
||||
@@ -302,39 +283,11 @@ export function TitlebarTabStrip(props: {
|
||||
hidden: true,
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous"),
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next"),
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous.unseen"),
|
||||
keybind: "shift+alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]),
|
||||
},
|
||||
{
|
||||
id: "session.next.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next.unseen"),
|
||||
keybind: "shift+alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]),
|
||||
},
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) {
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include)
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -437,7 +390,6 @@ export function TitlebarTabStrip(props: {
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onUnreadChange={(value) => setUnread(id, value)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
|
||||
@@ -70,20 +70,12 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-unread-slot"] {
|
||||
inset-inline-end: 44px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-link"] {
|
||||
padding-inline-end: 38px;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab][data-unread="true"] [data-slot="tab-link"] {
|
||||
padding-inline-end: 58px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-title"] {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { fileContentFromBytes, MAX_MEDIA_BYTES } from "./artifact"
|
||||
import { approxBytes } from "./content-cache"
|
||||
|
||||
describe("fileContentFromBytes", () => {
|
||||
test("keeps media bytes with a mime type, including videos past 25 MiB", () => {
|
||||
const png = new Uint8Array([137, 80, 78, 71])
|
||||
expect(fileContentFromBytes("a.png", png)).toEqual({
|
||||
type: "binary",
|
||||
content: "",
|
||||
bytes: png,
|
||||
mimeType: "image/png",
|
||||
})
|
||||
|
||||
const video = new Uint8Array(40 * 1024 * 1024)
|
||||
const content = fileContentFromBytes("arcade-update-draft.mp4", video)
|
||||
expect(content).toEqual({
|
||||
type: "binary",
|
||||
content: "",
|
||||
bytes: video,
|
||||
mimeType: "video/mp4",
|
||||
})
|
||||
expect(approxBytes(content)).toBe(video.byteLength)
|
||||
test("keeps media as base64 with a mime type", () => {
|
||||
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
|
||||
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
|
||||
})
|
||||
|
||||
test("decodes text and svg with a mime type", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { artifactKind, artifactMime, type ArtifactKind } from "@opencode/util/artifact"
|
||||
import type { FileContent } from "@opencode/gui-extensions/sdk"
|
||||
import { bytesToBase64 } from "@opencode/util/base64"
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
|
||||
/** Kinds whose raw bytes are kept so media elements and binary viewers can read them without a text round trip. */
|
||||
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
|
||||
const binaryKinds = new Set<ArtifactKind>([
|
||||
"image",
|
||||
"audio",
|
||||
@@ -18,8 +19,8 @@ function isBinaryBytes(bytes: Uint8Array) {
|
||||
return bytes.subarray(0, 8192).includes(0)
|
||||
}
|
||||
|
||||
/** Media above this stays a placeholder so the in-memory file cache stays bounded. */
|
||||
export const MAX_MEDIA_BYTES = 256 * 1024 * 1024
|
||||
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
|
||||
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
|
||||
const kind = artifactKind(path)
|
||||
@@ -28,7 +29,7 @@ export function fileContentFromBytes(path: string, bytes: Uint8Array): FileConte
|
||||
if (binaryKinds.has(kind)) {
|
||||
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
|
||||
|
||||
return { type: "binary", content: "", bytes, mimeType }
|
||||
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
|
||||
}
|
||||
|
||||
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
|
||||
|
||||
Loaded 100 of 767 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user