Compare commits

..
Author SHA1 Message Date
Dax Raad 5079bf863e expect error 2026-03-26 20:41:12 -04:00
Dax Raad 57f7d39281 sync 2026-03-26 20:40:28 -04:00
Dax Raad 282ab0f67d Merge branch 'node-pty' into pr-18335 2026-03-26 19:04:00 -04:00
Dax Raad ada7b11fd6 Merge branch 'dev' into pr-18335
Bring the PR up to date with the latest server, workspace, and UI changes from dev while keeping the PR's GitLab dependency and PTY merge resolutions.
2026-03-26 18:40:41 -04:00
Dax a6bff14a78 Merge branch 'dev' into refactor/hono-server 2026-03-20 11:05:04 -04:00
Dax 37ff5aaa5c Merge branch 'dev' into refactor/hono-server 2026-03-20 10:02:09 -04:00
Dax b77c797c0f Merge branch 'dev' into refactor/hono-server 2026-03-20 00:00:24 -04:00
Dax Raad 3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Dax Raad 65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax Raad f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad 9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad 2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad 08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad 0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad 850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Dax Raad 48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax Raad b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax Raad bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
Dax Raad 63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
Adam 0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam 6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Dax Raad 04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad 2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad 27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad 1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad 190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad 3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad 0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad 4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad 66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad 88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad 0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker 4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad 5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad 5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad 2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad 5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad 655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad 791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad 8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad 721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad 4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad 6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad 870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker 7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax 6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad 040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad 4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad 4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad 21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad 5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad 070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad 406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad 5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker 2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad 3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad 2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad 58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad 0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad 58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad 0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad 124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad 85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad 528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad 0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad 656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
723 changed files with 61579 additions and 186831 deletions
+1 -5
View File
@@ -11,7 +11,6 @@ adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR -atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth -danieljoshuanazareth
-danieljoshuanazareth -danieljoshuanazareth
edemaine edemaine
@@ -22,11 +21,8 @@ jayair
kitlangton kitlangton
kommander kommander
-opencode2026 -opencode2026
-opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot -OpenCodeEngineer bot that spams issues
+2 -3
View File
@@ -9,8 +9,7 @@ on:
jobs: jobs:
sync-locales: sync-locales:
if: false if: github.actor != 'opencode-agent[bot]'
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: write contents: write
@@ -35,7 +34,7 @@ jobs:
- name: Compute changed English docs - name: Compute changed English docs
id: changes id: changes
run: | run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true) FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range" echo "No English docs changed in push range"
-4
View File
@@ -17,10 +17,6 @@ on:
- "patches/**" - "patches/**"
- ".github/workflows/nix-hashes.yml" - ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu) # Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs. # do not produce byte-identical node_modules as native installs.
+3 -187
View File
@@ -98,130 +98,15 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: opencode-cli name: opencode-cli
path: | path: packages/opencode/dist
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri: build-tauri:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -267,14 +152,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
@@ -313,7 +190,6 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }} RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
@@ -370,35 +246,11 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron: build-electron:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -423,6 +275,7 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
platform_flag: --linux platform_flag: --linux
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -439,14 +292,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
@@ -481,7 +326,6 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }} RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
@@ -514,22 +358,6 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: opencode-electron-${{ matrix.settings.target }} name: opencode-electron-${{ matrix.settings.target }}
@@ -545,10 +373,8 @@ jobs:
needs: needs:
- version - version
- build-cli - build-cli
- sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -586,17 +412,7 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: if: needs.version.outputs.release
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta'
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
+54
View File
@@ -0,0 +1,54 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
+2 -49
View File
@@ -15,10 +15,6 @@ concurrency:
permissions: permissions:
contents: read contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
unit: unit:
@@ -41,11 +37,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -54,39 +45,8 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests - name: Run unit tests
run: bun turbo test:ci run: bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
@@ -110,11 +70,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -145,17 +100,15 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
if: always() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: | path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
-1
View File
@@ -25,7 +25,6 @@ target
# Local dev files # Local dev files
opencode-dev opencode-dev
UPCOMING_CHANGELOG.md
logs/ logs/
*.bun-build *.bun-build
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
+34
View File
@@ -0,0 +1,34 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`
+12 -35
View File
@@ -1,46 +1,23 @@
--- ---
model: opencode/gpt-5.4 model: opencode/kimi-k2.5
--- ---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. create UPCOMING_CHANGELOG.md
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
The input already contains the exact commit range since the last non-draft release. it should have sections
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
Before writing any entry you keep, inspect the real diff with ```
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can ## TUI
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
Rules: ## Desktop
- Write the final file with sections in this order: ## Core
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail** ## Misc
```
<changelog_input> fetch the latest github release for this repository to determine the last release version.
!`bun script/raw-changelog.ts $ARGUMENTS` find each PR that was merged since the last release
</changelog_input> for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
-223
View File
@@ -1,223 +0,0 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"nord0": "#2E3440",
"nord1": "#3B4252",
"nord2": "#434C5E",
"nord3": "#4C566A",
"nord4": "#D8DEE9",
"nord5": "#E5E9F0",
"nord6": "#ECEFF4",
"nord7": "#8FBCBB",
"nord8": "#88C0D0",
"nord9": "#81A1C1",
"nord10": "#5E81AC",
"nord11": "#BF616A",
"nord12": "#D08770",
"nord13": "#EBCB8B",
"nord14": "#A3BE8C",
"nord15": "#B48EAD"
},
"theme": {
"primary": {
"dark": "nord10",
"light": "nord9"
},
"secondary": {
"dark": "nord9",
"light": "nord9"
},
"accent": {
"dark": "nord7",
"light": "nord7"
},
"error": {
"dark": "nord11",
"light": "nord11"
},
"warning": {
"dark": "nord12",
"light": "nord12"
},
"success": {
"dark": "nord14",
"light": "nord14"
},
"info": {
"dark": "nord8",
"light": "nord10"
},
"text": {
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
"dark": "nord0",
"light": "nord6"
},
"backgroundPanel": {
"dark": "nord1",
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"light": "nord4"
},
"border": {
"dark": "nord2",
"light": "nord3"
},
"borderActive": {
"dark": "nord3",
"light": "nord2"
},
"borderSubtle": {
"dark": "nord2",
"light": "nord3"
},
"diffAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffHighlightRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
},
"diffContextBg": {
"dark": "nord1",
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
},
"markdownText": {
"dark": "nord4",
"light": "nord0"
},
"markdownHeading": {
"dark": "nord8",
"light": "nord10"
},
"markdownLink": {
"dark": "nord9",
"light": "nord9"
},
"markdownLinkText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCode": {
"dark": "nord14",
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
"dark": "nord12",
"light": "nord12"
},
"markdownStrong": {
"dark": "nord13",
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
"dark": "nord8",
"light": "nord10"
},
"markdownListEnumeration": {
"dark": "nord7",
"light": "nord7"
},
"markdownImage": {
"dark": "nord9",
"light": "nord9"
},
"markdownImageText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCodeBlock": {
"dark": "nord4",
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {
"dark": "nord9",
"light": "nord9"
},
"syntaxFunction": {
"dark": "nord8",
"light": "nord8"
},
"syntaxVariable": {
"dark": "nord7",
"light": "nord7"
},
"syntaxString": {
"dark": "nord14",
"light": "nord14"
},
"syntaxNumber": {
"dark": "nord15",
"light": "nord15"
},
"syntaxType": {
"dark": "nord7",
"light": "nord7"
},
"syntaxOperator": {
"dark": "nord9",
"light": "nord9"
},
"syntaxPunctuation": {
"dark": "nord4",
"light": "nord0"
}
}
}
-937
View File
@@ -1,937 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
const look = (map: Record<string, unknown>) => {
return {
panel: ink(map, "backgroundPanel", ui.panel),
border: ink(map, "border", ui.border),
text: ink(map, "text", ui.text),
muted: ink(map, "textMuted", ui.muted),
accent: ink(map, "primary", ui.accent),
selected: ink(map, "selectedListItemText", ui.text),
}
}
const tone = (api: TuiPluginApi) => {
return look(api.theme.current)
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiPluginApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiPluginApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiPluginApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiPluginApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiPluginApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginMeta
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
params?: Record<string, unknown>
}) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
const skin = look(map)
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const fill = [
skin.accent,
skin.muted,
ink(map, "info", ui.accent),
skin.text,
ink(map, "success", ui.accent),
ink(map, "warning", ui.accent),
ink(map, "secondary", ui.accent),
ink(map, "error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={fill[i]}>{line}</text>
))}
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
</text>
</box>
</box>
)
},
},
})
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
order,
slots: {
sidebar_content(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
},
},
})
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
]
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
api.lifecycle.onDispose(() => {
api.renderer.removePostProcessFn(post)
})
api.route.register([
{
name: route.screen,
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(api, value, keys)
for (const item of slot(api, value)) {
api.slots.register(item)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin
-1
View File
@@ -1 +0,0 @@
smoke-theme.json
-18
View File
@@ -1,18 +0,0 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}
@@ -0,0 +1,5 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
+381 -600
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -109,12 +109,6 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
appliesToProducts: [zenLiteProduct.id], appliesToProducts: [zenLiteProduct.id],
duration: "once", duration: "once",
}) })
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", { const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id, product: zenLiteProduct.id,
currency: "usd", currency: "usd",
@@ -130,7 +124,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900, priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
}, },
}) })
@@ -236,7 +229,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL, SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"), new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS, ...ZEN_MODELS,
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=", "x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=", "aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=", "aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE=" "x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
} }
} }
+1 -2
View File
@@ -20,7 +20,7 @@ let
in in
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "opencode-node_modules"; pname = "opencode-node_modules";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}"; version = "${packageJson.version}-${rev}";
src = lib.fileset.toSource { src = lib.fileset.toSource {
root = ../.; root = ../.;
@@ -54,7 +54,6 @@ stdenvNoCC.mkDerivation {
--filter '!./' \ --filter '!./' \
--filter './packages/opencode' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress --no-progress
-4
View File
@@ -3,7 +3,6 @@
stdenvNoCC, stdenvNoCC,
callPackage, callPackage,
bun, bun,
nodejs,
sysctl, sysctl,
makeBinaryWrapper, makeBinaryWrapper,
models-dev, models-dev,
@@ -20,7 +19,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [ nativeBuildInputs = [
bun bun
nodejs # for patchShebangs node_modules
installShellFiles installShellFiles
makeBinaryWrapper makeBinaryWrapper
models-dev models-dev
@@ -31,8 +29,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure runHook preConfigure
cp -R ${finalAttrs.node_modules}/. . cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure runHook postConfigure
''; '';
+6 -10
View File
@@ -26,9 +26,8 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.46", "@effect/platform-node": "4.0.0-beta.37",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -47,16 +46,14 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46", "effect": "4.0.0-beta.37",
"ai": "6.0.158", "ai": "5.0.124",
"cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.1", "marked": "17.0.1",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.51.0", "@playwright/test": "1.51.0",
"typescript": "5.8.2", "typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -71,8 +68,7 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10"
"@lydell/node-pty": "1.2.0-beta.10"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -92,7 +88,6 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"repository": { "repository": {
@@ -110,7 +105,6 @@
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter", "web-tree-sitter",
"electron" "electron"
], ],
@@ -120,6 +114,8 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch" "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
} }
+8 -7
View File
@@ -59,10 +59,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
### Using Fixtures ### Using Fixtures
- `page` - Playwright page - `page` - Playwright page
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.) - `sdk` - OpenCode SDK client for API calls
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`) - `gotoSession(sessionID?)` - Navigate to session
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
### Helper Functions ### Helper Functions
@@ -75,9 +73,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output - `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output - `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL - `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL - `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug - `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text - `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`): **Selectors** (`selectors.ts`):
@@ -127,9 +128,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
}) })
``` ```
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc. - Prefer `withSession(...)` for temp sessions
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory - In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up - This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly - Avoid calling `sdk.session.delete(...)` directly
### Timeouts ### Timeouts
+158 -86
View File
@@ -7,6 +7,7 @@ import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal" import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector, projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
@@ -205,7 +206,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
} }
async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed") const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true" return (await button.getAttribute("aria-expanded")) !== "true"
} }
@@ -236,7 +237,7 @@ async function errorBoundaryText(page: Page) {
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n") return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
} }
async function assertHealthy(page: Page, context: string) { export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page) const text = await errorBoundaryText(page)
if (!text) return if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`) console.log(`[e2e:error-boundary][${context}]\n${text}`)
@@ -311,7 +312,61 @@ export async function openSettings(page: Page) {
return dialog return dialog
} }
export async function createTestProject(input?: { serverUrl?: string }) { export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}` const id = `e2e-${path.basename(root)}`
@@ -320,14 +375,13 @@ export async function createTestProject(input?: { serverUrl?: string }) {
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id) await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root, cwd: root,
stdio: "ignore", stdio: "ignore",
}) })
return resolveDirectory(root, input?.serverUrl) return resolveDirectory(root)
} }
export async function cleanupTestProject(directory: string) { export async function cleanupTestProject(directory: string) {
@@ -376,22 +430,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) { export async function resolveSlug(slug: string) {
const directory = base64Decode(slug) const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory, input?.serverUrl) const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug } return { directory: resolved, slug: base64Encode(resolved), raw: slug }
} }
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) { export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory, input?.serverUrl) const target = await resolveDirectory(directory)
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitDir") await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
return resolveSlug(slug, input) return resolveSlug(slug)
.then((item) => item.directory) .then((item) => item.directory)
.catch(() => "") .catch(() => "")
}, },
@@ -401,33 +455,25 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSession( export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
page: Page, const target = await resolveDirectory(input.directory)
input: {
directory: string
sessionID?: string
serverUrl?: string
allowAnySession?: boolean
},
) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitSession") await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return false if (!slug) return false
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined) const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && !input.allowAnySession && current) return false if (!input.sessionID && current) return false
const state = await probeSession(page) const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false if (!input.sessionID && state?.sessionID) return false
if (state?.dir) { if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false if (dir !== target) return false
} }
@@ -443,9 +489,9 @@ export async function waitSession(
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) { export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory, serverUrl) const sdk = createSdk(directory)
const target = await resolveDirectory(directory, serverUrl) const target = await resolveDirectory(directory)
await expect await expect
.poll( .poll(
@@ -455,7 +501,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!data?.directory) return "" if (!data?.directory) return ""
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory) return resolveDirectory(data.directory).catch(() => data.directory)
}, },
{ timeout }, { timeout },
) )
@@ -532,15 +578,12 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
} }
export async function openSharePopover(page: Page) { export async function openSharePopover(page: Page) {
const scroller = page.locator(".scroll-view__viewport").first() const rightSection = page.locator(titlebarRightSelector)
await expect(scroller).toBeVisible() const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) await expect(shareButton).toBeVisible()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
const popoverBody = page const popoverBody = page
.locator('[data-component="popover-content"]') .locator(popoverBodySelector)
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first() .first()
@@ -550,13 +593,16 @@ export async function openSharePopover(page: Page) {
.catch(() => false) .catch(() => false)
if (!opened) { if (!opened) {
const menu = page.locator(dropdownMenuContentSelector).first() await shareButton.click()
await menuTrigger.click() await expect(popoverBody).toBeVisible()
await clickMenuItem(menu, /share/i)
await expect(menu).toHaveCount(0)
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
} }
return { rightSection: scroller, popoverBody } return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
} }
export async function clickListItem( export async function clickListItem(
@@ -620,9 +666,8 @@ export async function cleanupSession(input: {
sessionID: string sessionID: string
directory?: string directory?: string
sdk?: ReturnType<typeof createSdk> sdk?: ReturnType<typeof createSdk>
serverUrl?: string
}) { }) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined) const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory") if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined) await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined) const current = await status(sdk, input.sessionID).catch(() => undefined)
@@ -724,6 +769,40 @@ export async function seedSessionQuestion(
return { id: result.id } return { id: result.id }
} }
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTask( export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {
@@ -782,6 +861,36 @@ export async function seedSessionTask(
return result return result
} }
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) { export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([ const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []), sdk.question.list().then((x) => x.data ?? []),
@@ -871,57 +980,30 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
} }
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = () => const current = await page
page
.getByRole("button", { name: "New workspace" }) .getByRole("button", { name: "New workspace" })
.first() .first()
.isVisible() .isVisible()
.then((x) => x) .then((x) => x)
.catch(() => false) .catch(() => false)
if ((await current()) === enabled) return if (current === enabled) return
if (enabled) {
await page.reload()
await openSidebar(page)
if ((await current()) === enabled) return
}
const flip = async (timeout?: number) => { const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug) const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible() await expect(toggle).toBeVisible()
await expect(toggle).toBeEnabled({ timeout: 30_000 }) return toggle.click({ force: true, timeout })
const clicked = await toggle
.click({ force: true, timeout })
.then(() => true)
.catch(() => false)
if (clicked) return
await toggle.focus()
await page.keyboard.press("Enter")
} }
for (const timeout of [1500, undefined, undefined]) { const flipped = await flip(1500)
if ((await current()) === enabled) break
await flip(timeout)
.then(() => undefined)
.catch(() => undefined)
const matched = await expect
.poll(current, { timeout: 5_000 })
.toBe(enabled)
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (matched) break
}
if ((await current()) !== enabled) { if (!flipped) await flip()
await page.reload()
await openSidebar(page)
}
const expected = enabled ? "New workspace" : "New session" const expected = enabled ? "New workspace" : "New session"
await expect.poll(current, { timeout: 60_000 }).toBe(enabled) await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
} }
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
@@ -937,13 +1019,3 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
await expect(menu).toBeVisible() await expect(menu).toBeVisible()
return menu return menu
} }
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}
-141
View File
@@ -1,141 +0,0 @@
import { spawn } from "node:child_process"
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
type Handle = {
url: string
stop: () => Promise<void>
}
function freePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) reject(err)
else resolve(address.port)
})
})
})
}
async function waitForHealth(url: string, probe = "/global/health") {
const end = Date.now() + 120_000
let last = ""
while (Date.now() < end) {
try {
const res = await fetch(`${url}${probe}`)
if (res.ok) return
last = `status ${res.status}`
} catch (err) {
last = err instanceof Error ? err.message : String(err)
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
}
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (done(proc)) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100
function cap(input: string[]) {
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
}
function tail(input: string[]) {
return input.slice(-40).join("")
}
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
const port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = {
...process.env,
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
OPENCODE_E2E_LLM_URL: input?.llmUrl,
} satisfies Record<string, string | undefined>
const out: string[] = []
const err: string[] = []
const proc = spawn(
"bun",
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
{
cwd: opencodeDir,
env,
stdio: ["ignore", "pipe", "pipe"],
},
)
proc.stdout?.on("data", (chunk) => {
out.push(String(chunk))
cap(out)
})
proc.stderr?.on("data", (chunk) => {
err.push(String(chunk))
cap(err)
})
const url = `http://127.0.0.1:${port}`
try {
await waitForHealth(url)
} catch (error) {
proc.kill("SIGTERM")
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
throw new Error(
[
`Failed to start isolated e2e backend for ${label}`,
error instanceof Error ? error.message : String(error),
tail(out),
tail(err),
]
.filter(Boolean)
.join("\n"),
)
}
return {
url,
async stop() {
if (!done(proc)) {
proc.kill("SIGTERM")
await waitExit(proc)
}
if (!done(proc)) {
proc.kill("SIGKILL")
await waitExit(proc)
}
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
},
}
}
+65 -515
View File
@@ -1,251 +1,41 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import { ManagedRuntime } from "effect"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
import { startBackend } from "./backend"
import { import {
healthPhase, healthPhase,
cleanupSession, cleanupSession,
cleanupTestProject, cleanupTestProject,
createTestProject, createTestProject,
setHealthPhase, setHealthPhase,
seedProjects,
sessionIDFromUrl, sessionIDFromUrl,
waitSession,
waitSessionIdle,
waitSessionSaved,
waitSlug, waitSlug,
waitSession,
} from "./actions" } from "./actions"
import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
type LLMFixture = {
url: string
push: (...input: (Item | Reply)[]) => Promise<void>
pushMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
...input: (Item | Reply)[]
) => Promise<void>
textMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
value: string,
opts?: { usage?: Usage },
) => Promise<void>
toolMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
name: string,
input: unknown,
) => Promise<void>
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
tool: (name: string, input: unknown) => Promise<void>
toolHang: (name: string, input: unknown) => Promise<void>
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
fail: (message?: unknown) => Promise<void>
error: (status: number, body: unknown) => Promise<void>
hang: () => Promise<void>
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
calls: () => Promise<number>
wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number>
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
type LLMWorker = LLMFixture & {
reset: () => Promise<void>
}
type AssistantFixture = {
reply: LLMFixture["text"]
tool: LLMFixture["tool"]
toolHang: LLMFixture["toolHang"]
reason: LLMFixture["reason"]
fail: LLMFixture["fail"]
error: LLMFixture["error"]
hang: LLMFixture["hang"]
hold: LLMFixture["hold"]
calls: LLMFixture["calls"]
pending: LLMFixture["pending"]
}
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
const seedModel = (() => { type TestFixtures = {
const [providerID = "opencode", modelID = "big-pickle"] = ( sdk: ReturnType<typeof createSdk>
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle" gotoSession: (sessionID?: string) => Promise<void>
).split("/") withProject: <T>(
return { callback: (project: {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
function clean(value: string | null) {
return (value ?? "").replace(/\u200B/g, "").trim()
}
async function visit(page: Page, url: string) {
let err: unknown
for (const _ of [0, 1, 2]) {
try {
await page.goto(url)
return
} catch (cause) {
err = cause
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
await new Promise((resolve) => setTimeout(resolve, 300))
}
}
throw err
}
async function promptSend(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const sent = win.__opencode_e2e?.prompt?.sent
return {
started: sent?.started ?? 0,
count: sent?.count ?? 0,
sessionID: sent?.sessionID,
directory: sent?.directory,
}
})
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
}
type ProjectHandle = {
directory: string directory: string
slug: string slug: string
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void trackDirectory: (directory: string) => void
sdk: ReturnType<typeof createSdk> }) => Promise<T>,
} options?: { extra?: string[] },
) => Promise<T>
type ProjectOptions = {
extra?: string[]
model?: { providerID: string; modelID: string }
setup?: (directory: string) => Promise<void>
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
}
type ProjectFixture = ProjectHandle & {
open: (options?: ProjectOptions) => Promise<void>
prompt: (text: string) => Promise<string>
user: (text: string) => Promise<string>
shell: (cmd: string) => Promise<string>
}
type TestFixtures = {
llm: LLMFixture
assistant: AssistantFixture
project: ProjectFixture
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
} }
type WorkerFixtures = { type WorkerFixtures = {
_llm: LLMWorker
backend: {
url: string
sdk: (directory?: string) => ReturnType<typeof createSdk>
}
directory: string directory: string
slug: string slug: string
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
_llm: [
async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
reset: () => rt.runPromise(svc.reset),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
})
} finally {
await rt.dispose()
}
},
{ scope: "worker" },
],
backend: [
async ({ _llm }, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
llm: async ({ _llm }, use) => {
await _llm.reset()
await use({
url: _llm.url,
push: _llm.push,
pushMatch: _llm.pushMatch,
textMatch: _llm.textMatch,
toolMatch: _llm.toolMatch,
text: _llm.text,
tool: _llm.tool,
toolHang: _llm.toolHang,
reason: _llm.reason,
fail: _llm.fail,
error: _llm.error,
hang: _llm.hang,
hold: _llm.hold,
hits: _llm.hits,
calls: _llm.calls,
wait: _llm.wait,
inputs: _llm.inputs,
pending: _llm.pending,
misses: _llm.misses,
})
const pending = await _llm.pending()
if (pending > 0) {
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
}
},
assistant: async ({ llm }, use) => {
await use({
reply: llm.text,
tool: llm.tool,
toolHang: llm.toolHang,
reason: llm.reason,
fail: llm.fail,
error: llm.error,
hang: llm.hang,
hold: llm.hold,
calls: llm.calls,
pending: llm.pending,
})
},
page: async ({ page }, use) => { page: async ({ page }, use) => {
let boundary: string | undefined let boundary: string | undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
@@ -270,8 +60,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
if (boundary) throw new Error(boundary) if (boundary) throw new Error(boundary)
}, },
directory: [ directory: [
async ({ backend }, use) => { async ({}, use) => {
await use(await getWorktree(backend.url)) const directory = await getWorktree()
await use(directory)
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
@@ -281,324 +72,83 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
sdk: async ({ directory, backend }, use) => { sdk: async ({ directory }, use) => {
await use(backend.sdk(directory)) await use(createSdk(directory))
}, },
gotoSession: async ({ page, directory, backend }, use) => { gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory, serverUrl: backend.url }) await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await visit(page, sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { await waitSession(page, { directory, sessionID })
directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
} }
await use(gotoSession) await use(gotoSession)
}, },
project: async ({ page, llm, backend }, use) => { withProject: async ({ page }, use) => {
const item = makeProject(page, llm, backend) await use(async (callback, options) => {
try { const root = await createTestProject()
await use(item.project) const sessions = new Map<string, string>()
} finally { const dirs = new Set<string>()
await item.cleanup() await seedStorage(page, { directory: root, extra: options?.extra })
}
},
})
function makeProject(
page: Page,
llm: LLMFixture,
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
) {
let state:
| {
directory: string
slug: string
sdk: ReturnType<typeof createSdk>
sessions: Map<string, string>
dirs: Set<string>
}
| undefined
const need = () => {
if (state) return state
throw new Error("project.open() must be called first")
}
const trackSession = (sessionID: string, directory?: string) => {
const cur = need()
cur.sessions.set(sessionID, directory ?? cur.directory)
}
const trackDirectory = (directory: string) => {
const cur = need()
if (directory !== cur.directory) cur.dirs.add(directory)
}
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
const cur = need() await page.goto(sessionPath(root, sessionID))
await visit(page, sessionPath(cur.directory, sessionID)) await waitSession(page, { directory: root, sessionID })
await waitSession(page, {
directory: cur.directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
const open = async (options?: ProjectOptions) => { const trackSession = (sessionID: string, directory?: string) => {
if (state) return sessions.set(sessionID, directory ?? root)
const directory = await createTestProject({ serverUrl: backend.url })
const sdk = backend.sdk(directory)
await options?.setup?.(directory)
await seedStorage(page, {
directory,
extra: options?.extra,
model: options?.model,
serverUrl: backend.url,
})
state = {
directory,
slug: "",
sdk,
sessions: new Map(),
dirs: new Set(),
} }
await options?.beforeGoto?.({ directory, sdk })
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession() await gotoSession()
need().slug = await waitSlug(page) const slug = await waitSlug(page)
} return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
if (input.noReply) {
const cur = need()
const state = await page.evaluate(() => {
const model = (window as E2EWindow).__opencode_e2e?.model?.current
if (!model) return null
return {
dir: model.dir,
sessionID: model.sessionID,
agent: model.agent,
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
variant: model.variant ?? undefined,
}
})
const dir = state?.dir ?? cur.directory
const sdk = backend.sdk(dir)
const sessionID = state?.sessionID
? state.sessionID
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
if (!res.data?.id) throw new Error("Failed to create no-reply session")
return res.data.id
})
await sdk.session.prompt({
sessionID,
agent: state?.agent,
model: state?.model,
variant: state?.variant,
noReply: true,
parts: [{ type: "text", text }],
})
await visit(page, sessionPath(dir, sessionID))
const active = await waitSession(page, {
directory: dir,
sessionID,
serverUrl: backend.url,
})
trackSession(sessionID, active.directory)
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
return sessionID
}
const prev = await promptSend(page)
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
await llm.text("ok")
}
const prompt = page.locator(promptSelector).first()
const submit = async () => {
await expect(prompt).toBeVisible()
await prompt.click()
if (input.shell) {
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
}
await page.keyboard.type(text)
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
await page.keyboard.press("Enter")
const started = await expect
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
.toBeGreaterThan(prev.started)
.then(() => true)
.catch(() => false)
if (started) return
const send = page.getByRole("button", { name: "Send" }).first()
const enabled = await send
.isEnabled()
.then((x) => x)
.catch(() => false)
if (enabled) {
await send.click()
} else {
await prompt.click()
await page.keyboard.press("Enter")
}
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
}
await submit()
let next: { sessionID: string; directory: string } | undefined
await expect
.poll(
async () => {
const sent = await promptSend(page)
if (sent.count <= prev.count) return ""
if (!sent.sessionID || !sent.directory) return ""
next = { sessionID: sent.sessionID, directory: sent.directory }
return sent.sessionID
},
{ timeout: 90_000 },
)
.not.toBe("")
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
const active = await waitSession(page, {
directory: next.directory,
sessionID: next.sessionID,
serverUrl: backend.url,
})
trackSession(next.sessionID, active.directory)
if (!input.shell) {
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
}
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
return next.sessionID
}
const prompt = async (text: string) => {
return send(text, { noReply: false, shell: false })
}
const user = async (text: string) => {
return send(text, { noReply: true, shell: false })
}
const shell = async (cmd: string) => {
return send(cmd, { noReply: false, shell: true })
}
const cleanup = async () => {
const cur = state
if (!cur) return
setHealthPhase(page, "cleanup") setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(cur.sessions, ([sessionID, directory]) => Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
),
) )
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(cur.directory) await cleanupTestProject(root)
state = undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
} }
})
return {
project: {
open,
prompt,
user,
shell,
gotoSession,
trackSession,
trackDirectory,
get directory() {
return need().directory
}, },
get slug() { })
return need().slug
},
get sdk() {
return need().sdk
},
},
cleanup,
}
}
async function seedStorage(
page: Page,
input: {
directory: string
extra?: string[]
model?: { providerID: string; modelID: string }
serverUrl?: string
},
) {
const origin = input.serverUrl ?? serverUrl
await page.addInitScript(
(args: {
directory: string
serverUrl: string
extra: string[]
model: { providerID: string; modelID: string }
}) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const next = { ...(projects as Record<string, unknown>) }
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => {
const current = next[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
for (const directory of [args.directory, ...args.extra]) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
model: { enabled: true }, model: {
prompt: { enabled: true }, enabled: true,
terminal: { enabled: true, terminals: {} },
}
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
}, },
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel }, prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
) )
})
} }
export { expect } export { expect }
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { clickListItem } from "../actions" import { clickListItem } from "../actions"
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
+9 -15
View File
@@ -1,14 +1,14 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, project }) => { test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open() await withProject(async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
const open = async () => { const open = async () => {
const menu = await openProjectMenu(page, project.slug) const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true }) await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -31,19 +31,13 @@ test("dialog edit project updates name and startup script", async ({ page, proje
await dialog.getByRole("button", { name: "Save" }).click() await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect const header = page.locator(".group\\/project").first()
.poll( await expect(header).toContainText(name)
async () => {
await page.reload()
await openSidebar(page)
const reopened = await open() const reopened = await open()
const value = await reopened.getByLabel("Name").inputValue() await expect(reopened.getByLabel("Name")).toHaveValue(name)
const next = await reopened.getByLabel("Workspace startup script").inputValue() await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click() await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0) await expect(reopened).toHaveCount(0)
return `${value}\n${next}` })
},
{ timeout: 30_000 },
)
.toBe(`${name}\n${startup}`)
}) })
@@ -3,14 +3,15 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils" import { dirSlug } from "../utils"
test("closing active project navigates to another open project", async ({ page, project }) => { test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
@@ -20,13 +21,14 @@ test("closing active project navigates to another open project", async ({ page,
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug) const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true }) await clickMenuItem(menu, /^Close$/i, { force: true })
await expect await expect
.poll( .poll(
() => { () => {
const pathname = new URL(page.url()).pathname const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home" if (pathname === "/") return "home"
return "" return ""
}, },
@@ -43,6 +45,9 @@ test("closing active project navigates to another open project", async ({ page,
{ timeout: 15_000 }, { timeout: 15_000 },
) )
.toBe(0) .toBe(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -5,24 +5,27 @@ import {
createTestProject, createTestProject,
cleanupTestProject, cleanupTestProject,
openSidebar, openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled, setWorkspacesEnabled,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
test("can switch between projects from sidebar", async ({ page, project }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
async ({ directory }) => {
await defocus(page) await defocus(page)
const currentSlug = dirSlug(project.directory) const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click()
@@ -34,31 +37,35 @@ test("can switch between projects from sidebar", async ({ page, project }) => {
await currentButton.click() await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("switching back to a project opens the latest workspace session", async ({ page, project }) => { test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const raw = await waitSlug(page, [project.slug]) const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw) const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir) const space = await resolveDirectory(dir)
const next = dirSlug(space) const next = dirSlug(space)
project.trackDirectory(space) trackDirectory(space)
await openSidebar(page) await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
@@ -71,7 +78,19 @@ test("switching back to a project opens the latest workspace session", async ({
await waitSession(page, { directory: space }) await waitSession(page, { directory: space })
const created = await project.user("test") // Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -82,12 +101,15 @@ test("switching back to a project opens the latest workspace session", async ({
await otherButton.click({ force: true }) await otherButton.click({ force: true })
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(project.slug)).first() const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click({ force: true }) await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created }) await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -7,9 +7,11 @@ import {
setWorkspacesEnabled, setWorkspacesEnabled,
waitDir, waitDir,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -48,31 +50,45 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
} }
async function createSessionFromWorkspace( async function createSessionFromWorkspace(
project: Parameters<typeof test>[0]["project"],
page: Page, page: Page,
space: { slug: string; raw: string; directory: string }, space: { slug: string; raw: string; directory: string },
text: string, text: string,
) { ) {
await openWorkspaceNewSession(page, space) await openWorkspaceNewSession(page, space)
return project.user(text)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(text)
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
return sessionID
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open() await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, project.slug, []) const first = await createWorkspace(page, root, [])
project.trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, project.slug, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
project.trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second)
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
})
}) })
+102 -95
View File
@@ -19,10 +19,10 @@ import {
waitDir, waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { dirSlug } from "../utils" import { createSdk, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug const rootSlug = project.slug
await openSidebar(page) await openSidebar(page)
@@ -31,7 +31,6 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug])) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page) await openSidebar(page)
@@ -53,37 +52,37 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
return { rootSlug, slug: next.slug, directory: next.directory } return { rootSlug, slug: next.slug, directory: next.directory }
} }
test("can enable and disable workspaces from project menu", async ({ page, project }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, project.slug, false) await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
}) })
test("can create a workspace", async ({ page, project }) => { test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [project.slug])) const next = await resolveSlug(await waitSlug(page, [slug]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page) await openSidebar(page)
@@ -103,9 +102,12 @@ test("can create a workspace", async ({ page, project }) => {
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
})
}) })
test("non-git projects keep workspace mode disabled", async ({ page, project }) => { test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
@@ -114,7 +116,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, project })
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try { try {
await project.open({ extra: [nonGit] }) await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`) await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
@@ -124,16 +126,34 @@ test("non-git projects keep workspace mode disabled", async ({ page, project })
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally { } finally {
await cleanupTestProject(nonGit) await cleanupTestProject(nonGit)
} }
}) })
test("can rename a workspace", async ({ page, project }) => { test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project) const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}` const rename = `e2e workspace ${Date.now()}`
@@ -145,25 +165,17 @@ test("can rename a workspace", async ({ page, project }) => {
const item = page.locator(workspaceItemSelector(slug)).first() const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible() await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first() const input = item.locator(inlineInputSelector).first()
const shown = await input
.isVisible()
.then((x) => x)
.catch(() => false)
if (!shown) {
const retry = await openWorkspaceMenu(page, slug)
await clickMenuItem(retry, /^Rename$/i, { force: true })
await expect(retry).toHaveCount(0)
}
await expect(input).toBeVisible() await expect(input).toBeVisible()
await input.fill(rename) await input.fill(rename)
await input.press("Enter") await input.press("Enter")
await expect(item).toContainText(rename) await expect(item).toContainText(rename)
}) })
})
test("can reset a workspace", async ({ page, project }) => { test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md") const readme = path.join(createdDir, "README.md")
@@ -184,7 +196,7 @@ test("can reset a workspace", async ({ page, project }) => {
await expect await expect
.poll(async () => { .poll(async () => {
const files = await project.sdk.file const files = await sdk.file
.status({ directory: createdDir }) .status({ directory: createdDir })
.then((r) => r.data ?? []) .then((r) => r.data ?? [])
.catch(() => []) .catch(() => [])
@@ -199,17 +211,17 @@ test("can reset a workspace", async ({ page, project }) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const files = await project.sdk.file const files = await sdk.file
.status({ directory: createdDir }) .status({ directory: createdDir })
.then((r) => r.data ?? []) .then((r) => r.data ?? [])
.catch(() => []) .catch(() => [])
return files.length return files.length
}, },
{ timeout: 120_000 }, { timeout: 60_000 },
) )
.toBe(0) .toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original) await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect await expect
.poll(async () => { .poll(async () => {
@@ -220,11 +232,59 @@ test("can reset a workspace", async ({ page, project }) => {
}) })
.toBe(false) .toBe(false)
}) })
})
test("can reorder workspaces by drag and drop", async ({ page, project }) => { test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const rootSlug = project.slug await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => { const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
@@ -265,17 +325,16 @@ test("can reorder workspaces by drag and drop", async ({ page, project }) => {
await page.mouse.up() await page.mouse.up()
} }
try {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
const workspaces = [] as { directory: string; slug: string }[]
for (const _ of [0, 1]) { for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url()) const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
workspaces.push(next) workspaces.push(next)
await openSidebar(page) await openSidebar(page)
@@ -309,60 +368,8 @@ test("can reorder workspaces by drag and drop", async ({ page, project }) => {
await drag(from, to) await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to]) await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
}) })
test("can delete a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
const directory = created.directory
const slug = dirSlug(directory)
project.trackDirectory(directory)
await page.reload()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
await expect
.poll(
async () => {
const worktrees = await project.sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await project.sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
}) })
-15
View File
@@ -1,15 +0,0 @@
type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) {
return JSON.stringify(hit.body)
}
/**
* Match requests whose body contains the exact serialized tool input.
* The seed prompts embed JSON.stringify(input) in the prompt text, which
* gets escaped again inside the JSON body — so we double-escape to match.
*/
export function inputMatch(input: unknown) {
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
return (hit: Hit) => bodyText(hit).includes(escaped)
}
+30 -8
View File
@@ -1,25 +1,47 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, withSession } from "../actions" import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds // Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over // the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => { test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection // Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
const token = `E2E_ASYNC_${Date.now()}` await gotoSession()
await project.open()
await assistant.reply(token)
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) const token = `E2E_ASYNC_${Date.now()}`
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
}
}) })
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
@@ -1,88 +0,0 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
type Probe = {
agent?: string
model?: { providerID: string; modelID: string; name?: string }
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function state(page: Page) {
const value = await probe(page)
if (!value) throw new Error("Failed to resolve model selection probe")
return value
}
async function ready(page: Page) {
const prompt = page.locator(promptSelector)
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("focus")
return prompt
}
async function body(prompt: Locator) {
return prompt.evaluate((el) => (el as HTMLElement).innerText)
}
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
test.skip(!next, "only one agent available")
if (!next) return
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
next,
)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" agent")
await expect.poll(() => body(prompt)).toContain("focus agent")
})
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
test.skip(!next, "only one model available")
if (!next) return
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" model")
await expect.poll(() => body(prompt)).toContain("focus model")
})
+67 -29
View File
@@ -1,12 +1,10 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText } from "../actions" import { withSession } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -15,15 +13,54 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) { async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
} }
async function reply(sdk: Sdk, sessionID: string, token: string) { async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token) await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} }
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -42,31 +79,36 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
.toContain(token) .toContain(token)
} }
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => { test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}` const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}` const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}` const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}` const draft = `draft ${Date.now()}`
await project.open() await prompt.click()
await assistant.reply(firstToken) await page.keyboard.type(first)
const sessionID = await project.prompt(first) await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(project.sdk, sessionID, firstToken) await reply(sdk, session.id, firstToken)
await assistant.reply(secondToken) await prompt.click()
await project.prompt(second) await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(project.sdk, sessionID, secondToken) await reply(sdk, session.id, secondToken)
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, draft) await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("") await prompt.fill("")
await wait(page, "") await wait(page, "")
@@ -82,10 +124,15 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, "")
}) })
})
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}` const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}` const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}` const normalToken = `E2E_NORMAL_${Date.now()}`
@@ -93,29 +140,19 @@ test.fixme("shell history stays separate from normal prompt history", async ({ p
const second = `echo ${secondToken}` const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}` const normal = `Reply with exactly: ${normalToken}`
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(first) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
await shell(sdk, sessionID, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(second) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, sessionID, second, secondToken) await shell(sdk, session.id, second, secondToken)
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
@@ -138,9 +175,10 @@ test.fixme("shell history stays separate from normal prompt history", async ({ p
await page.keyboard.type(normal) await page.keyboard.type(normal)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, sessionID, normalToken) await reply(sdk, session.id, normalToken)
await prompt.click() await prompt.click()
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, normal) await wait(page, normal)
}) })
})
+27 -39
View File
@@ -1,7 +1,8 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { closeDialog, openSettings, withSession } from "../actions" import { sessionIDFromUrl } from "../actions"
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -10,34 +11,34 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
test("shell mode runs a command in the project directory", async ({ page, project }) => { test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await project.open() await withProject(async ({ directory, gotoSession, trackSession }) => {
const cmd = process.platform === "win32" ? "dir" : "command ls" const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { await gotoSession()
project.trackSession(session.id) await prompt.click()
await project.gotoSession(session.id) await page.keyboard.type("!")
const dialog = await openSettings(page) await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
const input = toggle.locator('[data-slot="switch-input"]').first() await page.keyboard.type(cmd)
await expect(toggle).toBeVisible() await page.keyboard.press("Enter")
if ((await input.getAttribute("aria-checked")) !== "true") {
await toggle.locator('[data-slot="switch-control"]').click() await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
await expect(input).toHaveAttribute("aria-checked", "true")
} const id = sessionIDFromUrl(page.url())
await closeDialog(page, dialog) if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await project.shell(cmd) trackSession(id, directory)
await expect await expect
.poll( .poll(
async () => { async () => {
const list = await project.sdk.session const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
.messages({ sessionID: session.id, limit: 50 })
.then((x) => x.data ?? [])
const msg = list.findLast( const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory, (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
) )
if (!msg) return if (!msg) return
@@ -50,25 +51,12 @@ test("shell mode runs a command in the project directory", async ({ page, projec
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return if (!output.includes("README.md")) return
return { cwd: project.directory, output } return { cwd: directory, output }
}, },
{ timeout: 90_000 }, { timeout: 90_000 },
) )
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") })) .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
}) })
}) })
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
await project.open()
const prompt = page.locator(promptSelector).first()
await expect(page.locator(promptModelSelector)).toHaveCount(1)
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await expect(page.locator(promptModelSelector)).toHaveCount(0)
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
})
@@ -22,16 +22,14 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("/share and /unshare update session share state", async ({ page, project }) => { test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await project.open() await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id) await seed(sdk, session.id)
await project.gotoSession(session.id) await gotoSession(session.id)
await prompt.click() await prompt.click()
await page.keyboard.type("/share") await page.keyboard.type("/share")
@@ -41,7 +39,7 @@ test("/share and /unshare update session share state", async ({ page, project })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -56,7 +54,7 @@ test("/share and /unshare update session share state", async ({ page, project })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
+36 -9
View File
@@ -1,7 +1,8 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText } from "../actions" import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => { test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const pageErrors: string[] = [] const pageErrors: string[] = []
@@ -10,16 +11,42 @@ test("can send a prompt and receive a reply", async ({ page, project, assistant
} }
page.on("pageerror", onPageError) page.on("pageerror", onPageError)
try { await gotoSession()
const token = `E2E_OK_${Date.now()}`
await project.open()
await assistant.reply(token)
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) const token = `E2E_OK_${Date.now()}`
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
} }
if (pageErrors.length > 0) { if (pageErrors.length > 0) {
+13 -2
View File
@@ -1,10 +1,16 @@
export const promptSelector = '[data-component="prompt-input"]' export const promptSelector = '[data-component="prompt-input"]'
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]' export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]` export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]' export const promptAgentSelector = '[data-component="prompt-agent-control"]'
@@ -24,7 +30,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) => export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
@@ -34,6 +40,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
@@ -41,6 +50,8 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]' export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]' export const inlineInputSelector = '[data-component="inline-input"]'
@@ -1,8 +1,7 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -11,54 +10,28 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
try { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
await project.open() const child = await seedSessionTask(sdk, {
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => { sessionID: session.id,
const taskInput = {
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
subagent_type: "general",
}
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
const child = await seedSessionTask(project.sdk, {
sessionID: session.id,
description: taskInput.description,
prompt: taskInput.prompt,
}) })
project.trackSession(child.sessionID)
await project.gotoSession(session.id) try {
await gotoSession(session.id)
const header = page.locator("[data-session-title]") const link = page
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) .locator("a.subagent-link")
const card = page
.locator('[data-component="task-tool-card"]')
.filter({ hasText: /open child session/i }) .filter({ hasText: /open child session/i })
.first() .first()
await expect(card).toBeVisible({ timeout: 30_000 }) await expect(link).toBeVisible({ timeout: 30_000 })
await card.click() await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title) await page.waitForTimeout(1000)
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description) expect(errs).toEqual([])
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
await expect
.poll(
() =>
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
left: getComputedStyle(el).paddingLeft,
right: getComputedStyle(el).paddingRight,
})),
{ timeout: 30_000 },
)
.toEqual({ left: "8px", right: "8px" })
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
} finally { } finally {
page.off("pageerror", onError) page.off("pageerror", onError)
} }
}) })
})
@@ -5,7 +5,7 @@ import {
type ComposerProbeState, type ComposerProbeState,
type ComposerWindow, type ComposerWindow,
} from "../../src/testing/session-composer" } from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions" import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
@@ -13,8 +13,6 @@ import {
sessionComposerDockSelector, sessionComposerDockSelector,
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
type Sdk = Parameters<typeof clearSessionDockSeed>[0] type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -23,13 +21,12 @@ async function withDockSession<T>(
sdk: Sdk, sdk: Sdk,
title: string, title: string,
fn: (session: { id: string; title: string }) => Promise<T>, fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void }, opts?: { permission?: PermissionRule[] },
) { ) {
const session = await sdk.session const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title }) .create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data) .then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id") if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try { try {
return await fn(session) return await fn(session)
} finally { } finally {
@@ -37,17 +34,6 @@ async function withDockSession<T>(
} }
} }
const defaultQuestions = [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
]
test.setTimeout(120_000) test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) { async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
@@ -65,14 +51,12 @@ async function clearPermissionDock(page: any, label: RegExp) {
} }
async function setAutoAccept(page: any, enabled: boolean) { async function setAutoAccept(page: any, enabled: boolean) {
const dialog = await openSettings(page) const button = page.locator('[data-action="prompt-permissions"]').first()
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() await expect(button).toBeVisible()
const input = toggle.locator('[data-slot="switch-input"]').first() const pressed = (await button.getAttribute("aria-pressed")) === "true"
await expect(toggle).toBeVisible() if (pressed === enabled) return
const checked = (await input.getAttribute("aria-checked")) === "true" await button.click()
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click() await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
await closeDialog(page, dialog)
} }
async function expectQuestionBlocked(page: any) { async function expectQuestionBlocked(page: any) {
@@ -244,7 +228,9 @@ async function withMockPermission<T>(
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child) if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({ await route.fulfill({
response: res, status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json), body: JSON.stringify(json),
}) })
} }
@@ -269,47 +255,48 @@ async function withMockPermission<T>(
} }
} }
test("default dock shows prompt input", async ({ page, project }) => { test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock default", async (session) => {
await withDockSession( await gotoSession(session.id)
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
await expect(page.locator(questionDockSelector)).toHaveCount(0) await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0) await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused() await expect(page.locator(promptSelector)).toBeFocused()
}, })
{ trackSession: project.trackSession },
)
}) })
test("auto-accept toggle works before first submit", async ({ page, project }) => { test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await project.open() await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true) await setAutoAccept(page, true)
await setAutoAccept(page, false) await setAutoAccept(page, false)
}) })
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => { test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSession( await withDockSeed(sdk, session.id, async () => {
project.sdk, await gotoSession(session.id)
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) await seedSessionQuestion(sdk, {
await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -320,81 +307,12 @@ test("blocked question flow unblocks after submit", async ({ page, llm, project
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}, })
{ trackSession: project.trackSession },
)
}) })
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => { test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await withDockSession( await gotoSession(session.id)
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
test("blocked permission flow supports allow once", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -416,18 +334,12 @@ test("blocked permission flow supports allow once", async ({ page, project }) =>
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession },
)
}) })
test("blocked permission flow supports reject", async ({ page, project }) => { test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSession( await gotoSession(session.id)
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -448,18 +360,12 @@ test("blocked permission flow supports reject", async ({ page, project }) => {
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession },
)
}) })
test("blocked permission flow supports allow always", async ({ page, project }) => { test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSession( await gotoSession(session.id)
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -481,13 +387,30 @@ test("blocked permission flow supports allow always", async ({ page, project })
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession },
)
}) })
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => { test("child session question request blocks parent dock and unblocks after submit", async ({
const questions = [ page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{ {
header: "Child input", header: "Child input",
question: "Pick one child option", question: "Pick one child option",
@@ -496,29 +419,7 @@ test("child session question request blocks parent dock and unblocks after submi
{ label: "Stop", description: "Stop child" }, { label: "Stop", description: "Stop child" },
], ],
}, },
] ],
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
async (session) => {
await project.gotoSession(session.id)
const child = await project.sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions,
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -530,30 +431,27 @@ test("child session question request blocks parent dock and unblocks after submi
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk, sessionID: child.id })
} }
}, })
{ trackSession: project.trackSession },
)
}) })
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => { test("child session permission request blocks parent dock and supports allow once", async ({
await project.open() page,
await withDockSession( sdk,
project.sdk, gotoSession,
"e2e composer dock child permission parent", }) => {
async (session) => { await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await project.gotoSession(session.id) await gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
const child = await project.sdk.session const child = await sdk.session
.create({ .create({
title: "e2e composer dock child permission", title: "e2e composer dock child permission",
parentID: session.id, parentID: session.id,
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withMockPermission( await withMockPermission(
@@ -578,21 +476,15 @@ test("child session permission request blocks parent dock and supports allow onc
}, },
) )
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk, sessionID: child.id })
} }
}, })
{ trackSession: project.trackSession },
)
}) })
test("todo dock transitions and collapse behavior", async ({ page, project }) => { test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await project.open() await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSession(
project.sdk,
"e2e composer dock todo",
async (session) => {
const dock = await todoDock(page, session.id) const dock = await todoDock(page, session.id)
await project.gotoSession(session.id) await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try { try {
@@ -616,31 +508,23 @@ test("todo dock transitions and collapse behavior", async ({ page, project }) =>
} finally { } finally {
await dock.clear() await dock.clear()
} }
}, })
{ trackSession: project.trackSession },
)
}) })
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
const questions = [ await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{ {
header: "Need input", header: "Need input",
question: "Pick one option", question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }], options: [{ label: "Continue", description: "Continue now" }],
}, },
] ],
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions,
}) })
await expectQuestionBlocked(page) await expectQuestionBlocked(page)
@@ -649,7 +533,5 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, projec
await page.keyboard.type("abc") await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0) await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
}, })
{ trackSession: project.trackSession },
)
}) })
@@ -1,9 +1,18 @@
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
promptSelector,
promptVariantSelector, promptVariantSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceNewSessionSelector, workspaceNewSessionSelector,
@@ -221,8 +230,32 @@ async function goto(page: Page, directory: string, sessionID?: string) {
await waitSession(page, { directory, sessionID }) await waitSession(page, { directory, sessionID })
} }
async function submit(project: Parameters<typeof test>[0]["project"], value: string) { async function submit(page: Page, value: string) {
return project.prompt(value) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -265,67 +298,75 @@ async function newWorkspaceSession(page: Page, slug: string) {
return waitSession(page, { directory: next.directory }).then((item) => item.directory) return waitSession(page, { directory: next.directory }).then((item) => item.directory)
} }
test("session model restore per session without leaking into new sessions", async ({ page, project }) => { test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory, gotoSession, trackSession }) => {
await project.gotoSession() await gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(project, `session variant ${Date.now()}`) const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload() await page.reload()
await waitSession(page, { directory: project.directory, sessionID: first }) await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState) await waitFooter(page, firstState)
await project.gotoSession() await gotoSession()
const fresh = await read(page) const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model) expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(project, `session model ${Date.now()}`) const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, project.directory, first) await goto(page, directory, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
await goto(page, project.directory, second) await goto(page, directory, second)
await waitFooter(page, secondState) await waitFooter(page, secondState)
await project.gotoSession() await gotoSession()
await page.reload()
await waitSession(page, { directory: project.directory })
await waitFooter(page, fresh) await waitFooter(page, fresh)
}) })
})
test("session model restore across workspaces", async ({ page, project }) => { test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
const root = project.directory await gotoSession()
await project.gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(project, `root session ${Date.now()}`) const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, project.slug, []) const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug) const oneDir = await newWorkspaceSession(page, one.slug)
project.trackDirectory(oneDir) trackDirectory(oneDir)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page) const secondKey = await currentModel(page)
const second = await submit(project, `workspace one ${Date.now()}`) const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, project.slug, [one.slug]) const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug) const twoDir = await newWorkspaceSession(page, two.slug)
project.trackDirectory(twoDir) trackDirectory(twoDir)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(project, `workspace two ${Date.now()}`) const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
@@ -339,14 +380,15 @@ test("session model restore across workspaces", async ({ page, project }) => {
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
}) })
})
test("variant preserved when switching agent modes", async ({ page, project }) => { test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory, gotoSession }) => {
await project.gotoSession() await gotoSession()
await ensureVariant(page, project.directory) await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page) const updated = await chooseDifferentVariant(page)
const available = await agents(page) const available = await agents(page)
@@ -360,3 +402,4 @@ test("variant preserved when switching agent modes", async ({ page, project }) =
await choose(page, promptAgentSelector, updated.agent) await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant }) await waitFooter(page, { agent: updated.agent, variant: updated.variant })
}) })
})
+36 -50
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions" import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { bodyText } from "../prompt/mock" import { createSdk } from "../utils"
const count = 14 const count = 14
@@ -40,19 +40,8 @@ function edit(file: string, prev: string, next: string) {
) )
} }
async function patchWithMock( async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
llm: Parameters<typeof test>[0]["llm"], await sdk.session.promptAsync({
sdk: Parameters<typeof withSession>[0],
sessionID: string,
patchText: string,
) {
const callsBefore = await llm.calls()
await llm.toolMatch(
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
"apply_patch",
{ patchText },
)
await sdk.session.prompt({
sessionID, sessionID,
agent: "build", agent: "build",
system: [ system: [
@@ -65,16 +54,7 @@ async function patchWithMock(
parts: [{ type: "text", text: "Apply the provided patch exactly once." }], parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
}) })
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true) await waitSessionIdle(sdk, sessionID, 120_000)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 120_000 },
)
.toBeGreaterThan(0)
} }
async function show(page: Parameters<typeof test>[0]["page"]) { async function show(page: Parameters<typeof test>[0]["page"]) {
@@ -253,7 +233,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
} }
} }
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}` const tag = `review-comment-${Date.now()}`
@@ -262,15 +242,16 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -299,8 +280,9 @@ test("review applies inline comment clicks without horizontal overflow", async (
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
}) })
}) })
})
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => { test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}` const tag = `review-file-comment-${Date.now()}`
@@ -309,15 +291,16 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -347,8 +330,10 @@ test("review file comments submit on click without clipping actions", async ({ p
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
}) })
}) })
})
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-${Date.now()}` const tag = `review-${Date.now()}`
@@ -358,15 +343,16 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
await page.setViewportSize({ width: 1600, height: 1000 }) await page.setViewportSize({ width: 1600, height: 1000 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list)) await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect await expect
.poll( .poll(
async () => { async () => {
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0 return info?.summary?.files ?? 0
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -376,7 +362,7 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -393,16 +379,15 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible() await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page) await expand(page)
await waitMark(page, hit.file, hit.mark) await waitMark(page, hit.file, hit.mark)
const row = page const row = page
.getByRole("heading", { .getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
level: 3,
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
})
.first() .first()
await expect(row).toBeVisible() await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" })) await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
@@ -411,12 +396,12 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
const prev = await spot(page, hit.file) const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`) if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file) const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : "" return typeof item?.after === "string" ? item.after : ""
}, },
@@ -438,3 +423,4 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
.toBeLessThanOrEqual(32) .toBeLessThanOrEqual(32)
}) })
}) })
})
@@ -49,16 +49,15 @@ async function seedConversation(input: {
return { prompt, userMessageID } return { prompt, userMessageID }
} }
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => { test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `undo_${Date.now()}` const token = `undo_${Date.now()}`
await project.open() await withProject(async (project) => {
const sdk = project.sdk const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -80,17 +79,17 @@ test("slash undo sets revert and restores prior prompt", async ({ page, project
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
}) })
}) })
})
test("slash redo clears revert and restores latest state", async ({ page, project }) => { test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `redo_${Date.now()}` const token = `redo_${Date.now()}`
await project.open() await withProject(async (project) => {
const sdk = project.sdk const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -127,18 +126,18 @@ test("slash redo clears revert and restores latest state", async ({ page, projec
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
}) })
}) })
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => { test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}` const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}`
await project.open() await withProject(async (project) => {
const sdk = project.sdk const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const first = await seedConversation({ const first = await seedConversation({
@@ -231,3 +230,4 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, project
await expect(secondMessage).toHaveCount(1) await expect(secondMessage).toHaveCount(1)
}) })
}) })
})
+21 -29
View File
@@ -31,16 +31,14 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("session can be renamed via header menu", async ({ page, project }) => { test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await project.open() await withSession(sdk, originalTitle, async (session) => {
await withSession(project.sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id)
project.trackSession(session.id) await gotoSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -56,7 +54,7 @@ test("session can be renamed via header menu", async ({ page, project }) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title return data?.title
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -67,22 +65,20 @@ test("session can be renamed via header menu", async ({ page, project }) => {
}) })
}) })
test("session can be archived via header menu", async ({ page, project }) => { test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e archive test ${stamp}` const title = `e2e archive test ${stamp}`
await project.open() await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
project.trackSession(session.id) await gotoSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i) await clickMenuItem(menu, /archive/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived return data?.time?.archived
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -94,15 +90,13 @@ test("session can be archived via header menu", async ({ page, project }) => {
}) })
}) })
test("session can be deleted via header menu", async ({ page, project }) => { test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e delete test ${stamp}` const title = `e2e delete test ${stamp}`
await project.open() await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
project.trackSession(session.id) await gotoSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i) await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i) await confirmDialog(page, /delete/i)
@@ -110,7 +104,7 @@ test("session can be deleted via header menu", async ({ page, project }) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session const data = await sdk.session
.get({ sessionID: session.id }) .get({ sessionID: session.id })
.then((r) => r.data) .then((r) => r.data)
.catch(() => undefined) .catch(() => undefined)
@@ -125,17 +119,15 @@ test("session can be deleted via header menu", async ({ page, project }) => {
}) })
}) })
test("session can be shared and unshared via header button", async ({ page, project }) => { test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now() const stamp = Date.now()
const title = `e2e share test ${stamp}` const title = `e2e share test ${stamp}`
await project.open() await withSession(sdk, title, async (session) => {
await withSession(project.sdk, title, async (session) => { await seedMessage(sdk, session.id)
project.trackSession(session.id) await gotoSession(session.id)
await project.gotoSession(session.id)
await project.prompt(`share seed ${stamp}`)
const shared = await openSharePopover(page) const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
@@ -149,7 +141,7 @@ test("session can be shared and unshared via header button", async ({ page, proj
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -167,7 +159,7 @@ test("session can be shared and unshared via header button", async ({ page, proj
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
+27 -37
View File
@@ -88,20 +88,10 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
return document.documentElement.getAttribute("data-theme") return document.documentElement.getAttribute("data-theme")
}) })
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
const trigger = select.locator('[data-slot="select-select-trigger"]')
const items = page.locator('[data-slot="select-select-item"]')
await trigger.click() await select.locator('[data-slot="select-select-trigger"]').click()
const open = await expect
.poll(async () => (await items.count()) > 0, { timeout: 5_000 }) const items = page.locator('[data-slot="select-select-item"]')
.toBe(true)
.then(() => true)
.catch(() => false)
if (!open) {
await trigger.click()
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
}
await expect(items.first()).toBeVisible()
const count = await items.count() const count = await items.count()
expect(count).toBeGreaterThan(1) expect(count).toBeGreaterThan(1)
@@ -169,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const dialog = await openSettings(page) const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector) const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "System Mono") await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
const initialFontFamily = await page.evaluate(() => const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -177,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const initialUIFamily = await page.evaluate(() => const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
) )
expect(initialFontFamily).toContain("ui-monospace") expect(initialFontFamily).toContain("IBM Plex Mono")
const next = "Test Mono" const next = "Test Mono"
@@ -195,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
mono: next, font: next,
}, },
}) })
@@ -216,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const dialog = await openSettings(page) const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector) const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "System Sans") await expect(input).toHaveAttribute("placeholder", "Inter")
const initialFontFamily = await page.evaluate(() => const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -224,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const initialCodeFamily = await page.evaluate(() => const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
) )
expect(initialFontFamily).toContain("ui-sans-serif") expect(initialFontFamily).toContain("Inter")
const next = "Test Sans" const next = "Test Sans"
@@ -242,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
sans: next, uiFont: next,
}, },
}) })
@@ -277,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
mono: "Reset Mono", font: "Reset Mono",
}, },
}) })
await input.clear() await input.clear()
await input.press("Space") await input.press("Space")
await expect(input).toHaveValue("") await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Mono") await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect await expect
.poll(async () => { .poll(async () => {
@@ -295,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
mono: "", font: "",
}, },
}) })
const fontFamily = await page.evaluate(() => const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
) )
expect(fontFamily).toContain("ui-monospace") expect(fontFamily).toContain("IBM Plex Mono")
expect(fontFamily).not.toContain("Reset Mono") expect(fontFamily).not.toContain("Reset Mono")
}) })
@@ -326,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
sans: "Reset Sans", uiFont: "Reset Sans",
}, },
}) })
await input.clear() await input.clear()
await input.press("Space") await input.press("Space")
await expect(input).toHaveValue("") await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Sans") await expect(input).toHaveAttribute("placeholder", "Inter")
await expect await expect
.poll(async () => { .poll(async () => {
@@ -344,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
sans: "", uiFont: "",
}, },
}) })
const fontFamily = await page.evaluate(() => const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(), getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
) )
expect(fontFamily).toContain("ui-sans-serif") expect(fontFamily).toContain("Inter")
expect(fontFamily).not.toContain("Reset Sans") expect(fontFamily).not.toContain("Reset Sans")
}) })
@@ -383,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
return raw ? JSON.parse(raw) : null return raw ? JSON.parse(raw) : null
}, settingsKey) }, settingsKey)
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono" const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans" const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click() await code.click()
await code.clear() await code.clear()
@@ -405,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
mono, font: mono,
sans, uiFont: sans,
}, },
}) })
@@ -425,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(updatedMono).not.toBe(initialMono) expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans) expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans) expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.mono).toBe(mono) expect(updatedSettings?.appearance?.font).toBe(mono)
expect(updatedSettings?.appearance?.sans).toBe(sans) expect(updatedSettings?.appearance?.uiFont).toBe(sans)
await closeDialog(page, dialog) await closeDialog(page, dialog)
await page.reload() await page.reload()
@@ -442,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
}) })
.toMatchObject({ .toMatchObject({
appearance: { appearance: {
mono, font: mono,
sans, uiFont: sans,
}, },
}) })
@@ -478,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(rehydratedMono).not.toBe(initialMono) expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans) expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans) expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.mono).toBe(mono) expect(rehydratedSettings?.appearance?.font).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans) expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
}) })
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => { test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
@@ -48,51 +48,57 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => { test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
async () => {
await openSidebar(page) await openSidebar(page)
const projectButton = page.locator(projectSwitchSelector(slug)).first() const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]') const card = page.locator('[data-component="hover-card-content"]')
await expect(projectButton).toBeVisible() await expect(project).toBeVisible()
await projectButton.hover() await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible() await expect(card.getByText(/recent sessions/i)).toBeVisible()
await projectButton.click() await page.mouse.down()
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => { test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
async () => {
await openSidebar(page) await openSidebar(page)
await defocus(page) await defocus(page)
const projectButton = page.locator(projectSwitchSelector(slug)).first() const project = page.locator(projectSwitchSelector(slug)).first()
await expect(projectButton).toBeVisible() await expect(project).toBeVisible()
let hit = false let hit = false
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
hit = await projectButton.evaluate((el) => { hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus") return el.matches(":focus") || !!el.parentElement?.matches(":focus")
}) })
if (hit) break if (hit) break
@@ -103,6 +109,9 @@ test("open sidebar project switch activates on first tabbed enter", async ({ pag
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -12,12 +12,12 @@ async function open(page: Page) {
return term return term
} }
test("terminal reconnects without replacing the pty", async ({ page, project }) => { test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await project.open() await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}` const token = `E2E_RECONNECT_${Date.now()}`
await project.gotoSession() await gotoSession()
const term = await open(page) const term = await open(page)
const id = await term.getAttribute("data-pty-id") const id = await term.getAttribute("data-pty-id")
@@ -43,3 +43,4 @@ test("terminal reconnects without replacing the pty", async ({ page, project })
timeout: 15_000, timeout: 15_000,
}) })
}) })
})
+15 -12
View File
@@ -36,16 +36,16 @@ async function store(page: Page, key: string) {
}, key) }, key)
} }
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => { test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}` const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}` const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first() const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first() const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one }) await runTerminal(page, { cmd: `echo ${one}`, token: one })
@@ -90,13 +90,14 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
) )
.toEqual({ first: true, second: false }) .toEqual({ first: true, second: false })
}) })
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => { test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
@@ -128,14 +129,15 @@ test("closing the active terminal tab falls back to the previous tab", async ({
) )
.toEqual({ count: 1, first: true }) .toEqual({ count: 1, first: true })
}) })
})
test("terminal tab can be renamed from the context menu", async ({ page, project }) => { test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}` const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await expect(tab).toContainText(/Terminal 1/) await expect(tab).toContainText(/Terminal 1/)
@@ -163,3 +165,4 @@ test("terminal tab can be renamed from the context menu", async ({ page, project
) )
.toBe(rename) .toBe(rename)
}) })
})
+7 -7
View File
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote" export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string, baseUrl = serverUrl) { export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl, directory, throwOnError: true }) return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
} }
export async function resolveDirectory(directory: string, baseUrl = serverUrl) { export async function resolveDirectory(directory: string) {
return createSdk(directory, baseUrl) return createSdk(directory)
.path.get() .path.get()
.then((x) => x.data?.directory ?? directory) .then((x) => x.data?.directory ?? directory)
} }
export async function getWorktree(baseUrl = serverUrl) { export async function getWorktree() {
const sdk = createSdk(undefined, baseUrl) const sdk = createSdk()
const result = await sdk.path.get() const result = await sdk.path.get()
const data = result.data const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`) if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree return data.worktree
} }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" style="background-color: var(--background-base)"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title> <title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
+2 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.3", "version": "1.3.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -15,7 +15,6 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src", "test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
@@ -47,10 +46,9 @@
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4", "@solid-primitives/timer": "1.4.4",
+1 -6
View File
@@ -7,11 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -24,7 +19,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers, workers,
reporter, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: { webServer: {
command, command,
url: baseURL, url: baseURL,
+4 -4
View File
@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir, OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session", OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano", OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app", OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true", OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string> } satisfies Record<string, string>
@@ -87,7 +87,7 @@ const runnerEnv = {
let seed: ReturnType<typeof Bun.spawn> | undefined let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false let cleaned = false
@@ -100,7 +100,7 @@ const cleanup = async () => {
const jobs = [ const jobs = [
inst?.Instance.disposeAll(), inst?.Instance.disposeAll(),
typeof server?.stop === "function" ? server.stop() : undefined, server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean) ].filter(Boolean)
await Promise.allSettled(jobs) await Promise.allSettled(jobs)
@@ -158,7 +158,7 @@ try {
const servermod = await import("../../opencode/src/server/server") const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance") inst = await import("../../opencode/src/project/instance")
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
+4 -12
View File
@@ -47,14 +47,9 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session") const Session = lazy(() => import("@/pages/session"))
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => ( const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Session /> <Session />
@@ -182,7 +177,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background" || type === "http") return false if (checkMode() === "background" || type === "http") return false
} }
}).pipe( }).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise, Effect.runPromise,
), ),
@@ -282,11 +278,7 @@ export function AppInterface(props: {
disableHealthCheck?: boolean disableHealthCheck?: boolean
}) { }) {
return ( return (
<ServerProvider <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
+2 -2
View File
@@ -1,7 +1,6 @@
import { useIsRouting, useLocation } from "@solidjs/router" import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js" import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -350,12 +349,13 @@ export function DebugBar() {
syncHeap() syncHeap()
start() start()
makeEventListener(document, "visibilitychange", vis) document.addEventListener("visibilitychange", vis)
onCleanup(() => { onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one) if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two) if (two !== 0) cancelAnimationFrame(two)
stop() stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect() for (const ob of obs) ob.disconnect()
}) })
}) })
@@ -86,7 +86,6 @@ const ModelList: Component<{
} }
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
@@ -94,31 +93,25 @@ export function ModelSelectorPopover(props: {
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
dismiss: Dismiss | null dismiss: "escape" | "outside" | null
}>({ }>({
open: false, open: false,
dismiss: null, dismiss: null,
}) })
const dialog = useDialog() const dialog = useDialog()
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => { const handleManage = () => {
close("manage") setStore("open", false)
void import("./dialog-manage-models").then((x) => { void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />) dialog.show(() => <x.DialogManageModels />)
}) })
} }
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") setStore("open", false)
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider />)
}) })
@@ -143,19 +136,21 @@ export function ModelSelectorPopover(props: {
<Kobalte.Content <Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
close("escape") setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
onPointerDownOutside={() => close("outside")} onPointerDownOutside={() => {
onFocusOutside={() => close("outside")} setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
const dismiss = store.dismiss if (store.dismiss === "outside") event.preventDefault()
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
setStore("dismiss", null) setStore("dismiss", null)
}} }}
> >
@@ -163,7 +158,7 @@ export function ModelSelectorPopover(props: {
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model} model={props.model}
onSelect={() => close("select")} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
+63 -56
View File
@@ -243,6 +243,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -280,31 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0 if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length return prompt.context.items().filter((item) => !!item.comment?.trim()).length
}) })
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => { const contextItems = createMemo(() => {
const items = prompt.context.items() const items = prompt.context.items()
@@ -510,15 +502,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getCursorPosition(editorRef) return getCursorPosition(editorRef)
} }
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => { const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor() const cursor = currentCursor()
renderEditor(parts) renderEditor(parts)
@@ -641,18 +624,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return if (!cmd) return
promptProbe.select(cmd.id) promptProbe.select(cmd.id)
closePopover() closePopover()
const images = imageAttachments()
if (cmd.type === "custom") { if (cmd.type === "custom") {
const text = `/${cmd.trigger} ` const text = `/${cmd.trigger} `
setEditorText(text) setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
focusEditorEnd() focusEditorEnd()
return return
} }
clearEditor() clearEditor()
prompt.set([...DEFAULT_PROMPT, ...images], 0) prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash") command.trigger(cmd.id, "slash")
} }
@@ -1079,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
@@ -1322,7 +1315,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => { onMouseDown={(e) => {
const target = e.target const target = e.target
if (!(target instanceof HTMLElement)) return if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
return return
} }
editorRef?.focus() editorRef?.focus()
@@ -1346,9 +1343,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"} spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
@@ -1400,17 +1394,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
<div class="flex items-center gap-1 pointer-events-auto"> <div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
disabled={store.mode !== "normal" || (!working() && blank())} disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="size-8" class="size-8"
style={buttons()} style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
</div> </div>
@@ -1473,10 +1467,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal" size="normal"
options={agentNames()} options={agentNames()}
current={local.agent.current()?.name ?? ""} current={local.agent.current()?.name ?? ""}
onSelect={(value) => { onSelect={local.agent.set}
local.agent.set(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base" valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()} triggerStyle={control()}
@@ -1485,7 +1476,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<Show when={store.mode !== "shell"}>
<div data-component="prompt-model-control"> <div data-component="prompt-model-control">
<Show <Show
when={providers.paid().length > 0} when={providers.paid().length > 0}
@@ -1540,7 +1530,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model", "data-action": "prompt-model",
}} }}
onClose={restoreFocus}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1569,10 +1558,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
options={variants()} options={variants()}
current={local.model.variant.current() ?? "default"} current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)} label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base" valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()} triggerStyle={control()}
@@ -1581,7 +1567,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</Show> <TooltipKeybind
placement="top"
gutter={8}
title={acceptLabel()}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,4 @@
import { onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -182,9 +181,15 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
onMount(() => { onMount(() => {
makeEventListener(document, "dragover", handleGlobalDragOver) document.addEventListener("dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave) document.addEventListener("dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop) document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
}) })
return { return {
@@ -100,30 +100,6 @@ describe("buildRequestParts", () => {
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
}) })
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => { test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
@@ -39,16 +39,6 @@ const absolute = (directory: string, path: string) => {
const fileQuery = (selection: FileSelection | undefined) => const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
@@ -148,21 +138,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
if (!comment) return [filePart] if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [ return [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@@ -178,7 +153,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
}), }),
} satisfies PromptRequestPart, } satisfies PromptRequestPart,
filePart, filePart,
...mentions,
] ]
}) })
@@ -1,6 +1,5 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt" import type { ImageAttachmentPart } from "@/context/prompt"
type PromptImageAttachmentsProps = { type PromptImageAttachmentsProps = {
@@ -23,7 +22,6 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}> <For each={props.attachments}>
{(attachment) => ( {(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group"> <div class="relative group">
<Show <Show
when={attachment.mime.startsWith("image/")} when={attachment.mime.startsWith("image/")}
@@ -52,7 +50,6 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<span class="text-10-regular text-white truncate block">{attachment.filename}</span> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div> </div>
</div> </div>
</Tooltip>
)} )}
</For> </For>
</div> </div>
@@ -146,7 +146,7 @@ beforeAll(async () => {
add: (value: { add: (value: {
directory?: string directory?: string
sessionID?: string sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => { }) => {
optimistic.push(value) optimistic.push(value)
optimisticSeeded.push( optimisticSeeded.push(
@@ -310,7 +310,8 @@ describe("prompt submit worktree selection", () => {
expect(optimistic[0]).toMatchObject({ expect(optimistic[0]).toMatchObject({
message: { message: {
agent: "agent", agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model" },
variant: "high",
}, },
}) })
}) })
@@ -13,7 +13,6 @@ import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { promptProbe } from "@/testing/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
@@ -121,7 +120,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: input.draft.agent, agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant }, model: input.draft.model,
variant: input.draft.variant,
} }
const add = () => const add = () =>
@@ -307,7 +307,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode) input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation() input.resetHistoryNavigation()
promptProbe.start()
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
@@ -427,7 +426,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
input.onSubmit?.() input.onSubmit?.()
if (mode === "shell") { if (mode === "shell") {
@@ -1,11 +1,11 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { import {
children, children,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
type JSXElement, type JSXElement,
onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Show, Show,
@@ -46,9 +46,12 @@ export function ServerRow(props: ServerRowProps) {
}) })
onMount(() => { onMount(() => {
if (typeof ResizeObserver !== "function") return
createResizeObserver([nameRef, versionRef], check)
check() check()
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
}) })
const tooltipValue = () => ( const tooltipValue = () => (
@@ -8,9 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { import {
monoDefault, monoDefault,
@@ -21,7 +19,6 @@ import {
sansInput, sansInput,
useSettings, useSettings,
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
@@ -67,9 +64,7 @@ const playDemoSound = (id: string | undefined) => {
export const SettingsGeneral: Component = () => { export const SettingsGeneral: Component = () => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const params = useParams()
const settings = useSettings() const settings = useSettings()
onMount(() => { onMount(() => {
@@ -81,31 +76,6 @@ export const SettingsGeneral: Component = () => {
}) })
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const check = () => { const check = () => {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
@@ -169,6 +139,11 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") }, { value: "dark", label: language.t("theme.scheme.dark") },
]) ])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() => const languageOptions = createMemo(() =>
language.locales.map((locale) => ({ language.locales.map((locale) => ({
value: locale, value: locale,
@@ -231,15 +206,6 @@ export const SettingsGeneral: Component = () => {
/> />
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")} description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -275,6 +241,24 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -251,7 +250,8 @@ function useKeyCapture(input: {
input.stop() input.stop()
} }
makeEventListener(document, "keydown", handle, { capture: true }) document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
}) })
} }
@@ -239,9 +239,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? []) const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => const plugins = createMemo(() => sync.data.config.plugin ?? [])
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
-5
View File
@@ -174,7 +174,6 @@ export const Terminal = (props: TerminalProps) => {
const auth = server.current?.http const auth = server.current?.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
const password = auth?.password ?? "" const password = auth?.password ?? ""
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -520,12 +519,8 @@ export const Terminal = (props: TerminalProps) => {
next.searchParams.set("directory", directory) next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek)) next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username next.username = username
next.password = password next.password = password
}
const socket = new WebSocket(next) const socket = new WebSocket(next)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
+5 -2
View File
@@ -2,7 +2,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
@@ -379,7 +378,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
} }
onMount(() => { onMount(() => {
makeEventListener(document, "keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
}) })
function register(cb: () => CommandOption[]): void function register(cb: () => CommandOption[]): void
+15 -33
View File
@@ -1,8 +1,7 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server" import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
@@ -106,8 +105,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const aborted = (error: unknown) => abortError.safeParse(error).success const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000 const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now() let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -124,11 +121,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
heartbeat = undefined heartbeat = undefined
} }
const start = () => { void (async () => {
if (started) return run while (!abort.signal.aborted) {
started = true
run = (async () => {
while (!abort.signal.aborted && started) {
attempt = new AbortController() attempt = new AbortController()
lastEventAt = Date.now() lastEventAt = Date.now()
const onAbort = () => { const onAbort = () => {
@@ -191,33 +185,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
clearHeartbeat() clearHeartbeat()
} }
if (abort.signal.aborted || !started) return if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS) await wait(RECONNECT_DELAY_MS)
} }
})().finally(() => { })().finally(flush)
run = undefined
flush()
})
return run
}
const stop = () => { const onVisibility = () => {
started = false if (typeof document === "undefined") return
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort() attempt?.abort()
}) }
}) if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => { onCleanup(() => {
stop() if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
abort.abort() abort.abort()
flush() flush()
}) })
@@ -231,11 +217,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
return { return {
url: currentServer.http.url, url: currentServer.http.url,
client: sdk, client: sdk,
event: { event: emitter,
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) { createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable")) if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
-20
View File
@@ -72,16 +72,10 @@ function createGlobalSync() {
let projectWritten = false let projectWritten = false
let bootedAt = 0 let bootedAt = 0
let bootingRoot = false let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => { onCleanup(() => {
active = false active = false
}) })
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => { const cacheProjects = () => {
setProjectCache( setProjectCache(
@@ -354,20 +348,6 @@ function createGlobalSync() {
} }
onMount(() => { onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
}
void bootstrap() void bootstrap()
}) })
@@ -43,10 +43,8 @@ function waitForPaint() {
const timer = setTimeout(finish, 50) const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(() => {
clearTimeout(timer) clearTimeout(timer)
finish() finish()
}, 0)
}) })
}) })
} }
@@ -89,6 +87,12 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
}) { }) {
const fast = [ const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() => () =>
retry(() => retry(() =>
input.globalSDK.global.config.get().then((x) => { input.globalSDK.global.config.get().then((x) => {
@@ -104,12 +108,6 @@ export async function bootstrapGlobal(input: {
] ]
const slow = [ const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() => () =>
retry(() => retry(() =>
input.globalSDK.project.list().then((x) => { input.globalSDK.project.list().then((x) => {
@@ -223,16 +221,12 @@ export async function bootstrapDirectory(input: {
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const fast = [ const fast = [
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() => () =>
seededProject seededProject
? Promise.resolve() ? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => () =>
seededPath seededPath
? Promise.resolve() ? Promise.resolve()
@@ -243,12 +237,13 @@ export async function bootstrapDirectory(input: {
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
}), }),
), ),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() => () =>
retry(() => retry(() =>
input.sdk.vcs.get().then((x) => { input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs const next = x.data ?? input.store.vcs
input.setStore("vcs", next) input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next) if (next?.branch) input.vcsCache.setStore("value", next)
}), }),
), ),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
@@ -304,6 +299,9 @@ export async function bootstrapDirectory(input: {
) )
}), }),
), ),
]
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
retry(() => retry(() =>
@@ -494,10 +494,8 @@ describe("applyDirectoryEvent", () => {
}) })
test("updates vcs branch in store and cache", () => { test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -513,8 +511,8 @@ describe("applyDirectoryEvent", () => {
}, },
}) })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) expect(cacheStore.value).toEqual({ branch: "feature/test" })
}) })
test("routes disposal and lsp events to side-effect handlers", () => { test("routes disposal and lsp events to side-effect handlers", () => {
@@ -1,6 +1,7 @@
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
@@ -8,13 +9,11 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -162,8 +161,8 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.diff": { case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } const props = event.properties as { sessionID: string; diff: FileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break break
} }
case "todo.updated": { case "todo.updated": {
@@ -178,7 +177,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "message.updated": { case "message.updated": {
const info = clean((event.properties as { info: Message }).info) const info = (event.properties as { info: Message }).info
const messages = input.store.message[info.sessionID] const messages = input.store.message[info.sessionID]
if (!messages) { if (!messages) {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
@@ -272,9 +271,9 @@ export function applyDirectoryEvent(input: {
break break
} }
case "vcs.branch.updated": { case "vcs.branch.updated": {
const props = event.properties as { branch?: string } const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break if (input.store.vcs?.branch === props.branch) break
const next = { ...input.store.vcs, branch: props.branch } const next = { branch: props.branch }
input.setStore("vcs", next) input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next) if (input.vcsCache) input.vcsCache.setStore("value", next)
break break
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => { test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -64,7 +64,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1") const m = msg("msg_1", "ses_1")
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -1,10 +1,10 @@
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = { type SessionCache = {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -2,6 +2,7 @@ import type {
Agent, Agent,
Command, Command,
Config, Config,
FileDiff,
LspStatus, LspStatus,
McpStatus, McpStatus,
Message, Message,
@@ -13,7 +14,6 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: SnapshotFileDiff[] [sessionID: string]: FileDiff[]
} }
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
+16 -30
View File
@@ -1,7 +1,6 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
@@ -14,8 +13,7 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path" import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_SIDEBAR_WIDTH = 344 const DEFAULT_PANEL_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280 const DEFAULT_TERMINAL_HEIGHT = 280
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -163,11 +161,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
return { return {
...fileTree, ...fileTree,
opened: true, opened: true,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width, width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
tab: "changes", tab: "changes",
} }
})() })()
@@ -232,7 +230,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
width: DEFAULT_SIDEBAR_WIDTH, width: DEFAULT_PANEL_WIDTH,
workspaces: {} as Record<string, boolean>, workspaces: {} as Record<string, boolean>,
workspacesDefault: false, workspacesDefault: false,
}, },
@@ -245,8 +243,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
panelOpened: true, panelOpened: true,
}, },
fileTree: { fileTree: {
opened: false, opened: true,
width: DEFAULT_FILE_TREE_WIDTH, width: DEFAULT_PANEL_WIDTH,
tab: "changes" as "changes" | "all", tab: "changes" as "changes" | "all",
}, },
session: { session: {
@@ -367,10 +365,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
flush() flush()
} }
makeEventListener(window, "pagehide", flush) window.addEventListener("pagehide", flush)
makeEventListener(document, "visibilitychange", handleVisibility) document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose() scroll.dispose()
}) })
}) })
@@ -543,26 +543,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
}) })
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => { onMount(() => {
sessionFrame = requestAnimationFrame(() => { Promise.all(
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => { server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree) return globalSync.project.loadSessions(project.worktree)
}), }),
) )
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
}) })
return { return {
@@ -642,32 +628,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
fileTree: { fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true), opened: createMemo(() => store.fileTree?.opened ?? true),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"), tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") { setTab(tab: "changes" | "all") {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
return return
} }
setStore("fileTree", "tab", tab) setStore("fileTree", "tab", tab)
}, },
open() { open() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", true) setStore("fileTree", "opened", true)
}, },
close() { close() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", false) setStore("fileTree", "opened", false)
}, },
toggle() { toggle() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", (x) => !x) setStore("fileTree", "opened", (x) => !x)
+3 -3
View File
@@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
export type ModelKey = { providerID: string; modelID: string; variant?: string } export type ModelKey = { providerID: string; modelID: string }
type State = { type State = {
agent?: string agent?: string
@@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(handoffKey(dir, session), next) handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined) setStore("draft", undefined)
}, },
restore(msg: { sessionID: string; agent: string; model: ModelKey }) { restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id() const session = id()
if (!session) return if (!session) return
if (msg.sessionID !== session) return if (msg.sessionID !== session) return
@@ -383,7 +383,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, { setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
model: msg.model, model: msg.model,
variant: msg.model.variant ?? null, variant: msg.variant ?? null,
}) })
}, },
}, },
+1 -9
View File
@@ -94,11 +94,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@@ -206,10 +202,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current_ = current() const current_ = current()
if (!current_) return if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(current_)) onCleanup(startHealthPolling(current_))
}) })
+24 -30
View File
@@ -32,8 +32,8 @@ export interface Settings {
} }
appearance: { appearance: {
fontSize: number fontSize: number
mono: string font: string
sans: string uiFont: string
} }
keybinds: Record<string, string> keybinds: Record<string, string>
permissions: { permissions: {
@@ -43,18 +43,20 @@ export interface Settings {
sounds: SoundSettings sounds: SoundSettings
} }
export const monoDefault = "System Mono" export const monoDefault = "IBM Plex Mono"
export const sansDefault = "System Sans" export const sansDefault = "Inter"
const monoFallback = const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = monoFallback const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
const sansBase = sansFallback const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
const monoKey = "ibm-plex-mono"
function input(font: string | undefined) { function input(font: string | undefined, key?: string) {
return font ?? "" if (!font || font === key || !font.trim()) return ""
return font
} }
function family(font: string) { function family(font: string) {
@@ -62,14 +64,14 @@ function family(font: string) {
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
} }
function stack(font: string | undefined, base: string) { function stack(font: string | undefined, base: string, key?: string) {
const value = font?.trim() ?? "" const value = input(font, key).trim()
if (!value) return base if (!value) return base
return `${family(value)}, ${base}` return `${family(value)}, ${base}`
} }
export function monoInput(font: string | undefined) { export function monoInput(font: string | undefined) {
return input(font) return input(font, monoKey)
} }
export function sansInput(font: string | undefined) { export function sansInput(font: string | undefined) {
@@ -77,7 +79,7 @@ export function sansInput(font: string | undefined) {
} }
export function monoFontFamily(font: string | undefined) { export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase) return stack(font, monoBase, monoKey)
} }
export function sansFontFamily(font: string | undefined) { export function sansFontFamily(font: string | undefined) {
@@ -98,8 +100,8 @@ const defaultSettings: Settings = {
}, },
appearance: { appearance: {
fontSize: 14, fontSize: 14,
mono: "", font: "",
sans: "", uiFont: "",
}, },
keybinds: {}, keybinds: {},
permissions: { permissions: {
@@ -132,13 +134,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const root = document.documentElement const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono)) root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
}) })
return { return {
@@ -155,12 +152,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) { setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value) setStore("general", "releaseNotes", value)
}, },
followup: withFallback( followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") { setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value) setStore("general", "followup", value)
}, },
showReasoningSummaries: withFallback( showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries, () => store.general?.showReasoningSummaries,
@@ -195,13 +189,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFontSize(value: number) { setFontSize(value: number) {
setStore("appearance", "fontSize", value) setStore("appearance", "fontSize", value)
}, },
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono), font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
setFont(value: string) { setFont(value: string) {
setStore("appearance", "mono", value.trim() ? value : "") setStore("appearance", "font", value.trim() ? value : "")
}, },
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans), uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
setUIFont(value: string) { setUIFont(value: string) {
setStore("appearance", "sans", value.trim() ? value : "") setStore("appearance", "uiFont", value.trim() ? value : "")
}, },
}, },
keybinds: { keybinds: {
+4 -4
View File
@@ -13,7 +13,6 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -301,7 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
) )
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return { return {
@@ -417,7 +416,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: input.agent, agent: input.agent,
model: { ...input.model, variant: input.variant }, model: input.model,
variant: input.variant,
} }
const [, setStore] = target() const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts }) setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
@@ -510,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflightDiff, key, () => return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => { retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}), }),
) )
}, },
-4
View File
@@ -238,8 +238,6 @@ export const dict = {
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt", "prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit", "prompt.mode.shell.exit": "esc to exit",
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
"session.child.backToParent": "Back to main session.",
"prompt.example.1": "Fix a TODO in the codebase", "prompt.example.1": "Fix a TODO in the codebase",
"prompt.example.2": "What is the tech stack of this project?", "prompt.example.2": "What is the tech stack of this project?",
@@ -537,8 +535,6 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository", "session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes", "session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open", "session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files", "session.files.all": "All files",
-40
View File
@@ -1,46 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@layer components { @layer components {
@keyframes session-progress-whip {
0% {
clip-path: inset(0 100% 0 0 round 999px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
clip-path: inset(0 0 0 0 round 999px);
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
100% {
clip-path: inset(0 0 0 100% round 999px);
}
}
[data-component="session-progress"] {
position: absolute;
inset: 0 0 auto;
height: 2px;
overflow: hidden;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease-out;
}
[data-component="session-progress"][data-state="hiding"] {
opacity: 0;
}
[data-component="session-progress-bar"] {
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path;
}
[data-component="getting-started"] { [data-component="getting-started"] {
container-type: inline-size; container-type: inline-size;
container-name: getting-started; container-name: getting-started;
+31 -7
View File
@@ -12,7 +12,6 @@ import {
untrack, untrack,
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -150,6 +149,7 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
@@ -193,6 +193,7 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => { onActivate: (directory) => {
globalSync.child(directory) globalSync.child(directory)
setState("hoverProject", directory) setState("hoverProject", directory)
setState("hoverSession", undefined)
}, },
}) })
@@ -214,11 +215,18 @@ export default function Layout(props: ParentProps) {
if (document.visibilityState !== "hidden") return if (document.visibilityState !== "hidden") return
reset() reset()
} }
makeEventListener(window, "pointerup", stop) window.addEventListener("pointerup", stop)
makeEventListener(window, "pointercancel", stop) window.addEventListener("pointercancel", stop)
makeEventListener(window, "blur", stop) window.addEventListener("blur", stop)
makeEventListener(window, "blur", blur) window.addEventListener("blur", blur)
makeEventListener(document, "visibilitychange", hide) document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
}) })
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -229,6 +237,7 @@ export default function Layout(props: ParentProps) {
aim.reset() aim.reset()
} }
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => { const disarm = () => {
if (navLeave.current === undefined) return if (navLeave.current === undefined) return
@@ -238,6 +247,7 @@ export default function Layout(props: ParentProps) {
const reset = () => { const reset = () => {
disarm() disarm()
setState("hoverSession", undefined)
setHoverProject(undefined) setHoverProject(undefined)
} }
@@ -248,6 +258,7 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => { navLeave.current = window.setTimeout(() => {
navLeave.current = undefined navLeave.current = undefined
setHoverProject(undefined) setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300) }, 300)
} }
@@ -1383,7 +1394,8 @@ export default function Layout(props: ParentProps) {
} }
handleDeepLinks(drainPendingDeepLinks(window)) handleDeepLinks(drainPendingDeepLinks(window))
makeEventListener(window, deepLinkEvent, handler as EventListener) window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
}) })
async function renameProject(project: LocalProject, next: string) { async function renameProject(project: LocalProject, next: string) {
@@ -1967,6 +1979,9 @@ export default function Layout(props: ParentProps) {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering, sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
@@ -1995,6 +2010,7 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2013,10 +2029,15 @@ export default function Layout(props: ParentProps) {
sessionProps: { sessionProps: {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
}, },
setHoverSession,
} }
const SidebarPanel = (panelProps: { const SidebarPanel = (panelProps: {
@@ -2027,6 +2048,7 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => { const projectName = createMemo(() => {
const item = project() const item = project()
@@ -2228,6 +2250,7 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
</div> </div>
</> </>
@@ -2272,6 +2295,7 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
)} )}
</For> </For>
@@ -8,7 +8,6 @@ import {
} from "./deep-links" } from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { import {
childSessionOnPath,
displayName, displayName,
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
@@ -199,19 +198,6 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root") expect(result?.id).toBe("root")
}) })
test("finds the direct child on the active session path", () => {
const list = [
session({ id: "root", directory: "/workspace" }),
session({ id: "child", directory: "/workspace", parentID: "root" }),
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
]
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
test("formats fallback project display name", () => { test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+11 -10
View File
@@ -46,17 +46,18 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => { export const childMapByParent = (sessions: Session[] | undefined) => {
if (!activeID || activeID === rootID) return const map = new Map<string, string[]>()
const map = new Map((sessions ?? []).map((session) => [session.id, session])) for (const session of sessions ?? []) {
let id = activeID if (!session.parentID) continue
const existing = map.get(session.parentID)
while (id) { if (existing) {
const session = map.get(id) existing.push(session.id)
if (!session?.parentID) return continue
if (session.parentID === rootID) return session
id = session.parentID
} }
map.set(session.parentID, [session.id])
}
return map
} }
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
+147 -40
View File
@@ -1,21 +1,23 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -36,7 +38,6 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
) )
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return ( return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip"> <div class="size-full rounded overflow-clip">
@@ -71,10 +72,13 @@ export type SessionItemProps = {
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
showTooltip?: boolean popover?: boolean
showChild?: boolean children: Map<string, string[]>
level?: number
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -90,30 +94,32 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean> hasPermissions: Accessor<boolean>
hasError: Accessor<boolean> hasError: Accessor<boolean>
unseenCount: Accessor<number> unseenCount: Accessor<number>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
warmHover: () => void
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
}): JSX.Element => { cancelHoverPrefetch: () => void
const title = () => sessionTitle(props.session.title) }): JSX.Element => (
return (
<A <A
href={`/${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress} onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus} onFocus={props.warmFocus}
onClick={() => { onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return if (props.sidebarOpened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
<div <div
class="shrink-0 size-6 flex items-center justify-center" class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
> >
<Switch> <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}> <Match when={props.isWorking()}>
<Spinner class="size-[15px]" /> <Spinner class="size-[15px]" />
</Match> </Match>
@@ -128,14 +134,72 @@ const SessionRow = (props: {
</Match> </Match>
</Switch> </Switch>
</div> </div>
</Show> <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</A> </A>
) )
const SessionHoverPreview = (props: {
mobile?: boolean
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
session: Session
sidebarHovering: Accessor<boolean>
hoverReady: Accessor<boolean>
hoverMessages: Accessor<UserMessage[] | undefined>
language: ReturnType<typeof useLanguage>
isActive: Accessor<boolean>
slug: string
setHoverSession: (id: string | undefined) => void
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
} }
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
@@ -165,13 +229,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
) )
}) })
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) const tint = createMemo(() => {
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
const currentChild = createMemo(() => {
if (!props.showChild) return
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
}) })
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
)
const hoverReady = createMemo(() => hoverMessages() !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const warm = (span: number, priority: "high" | "low") => { const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.() const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
@@ -192,6 +261,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
} }
} }
const hoverPrefetch = {
current: undefined as ReturnType<typeof setTimeout> | undefined,
}
const cancelHoverPrefetch = () => {
if (hoverPrefetch.current === undefined) return
clearTimeout(hoverPrefetch.current)
hoverPrefetch.current = undefined
}
const scheduleHoverPrefetch = () => {
warm(1, "high")
if (hoverPrefetch.current !== undefined) return
hoverPrefetch.current = setTimeout(() => {
hoverPrefetch.current = undefined
warm(2, "low")
}, 80)
}
onCleanup(cancelHoverPrefetch)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
const item = ( const item = (
<SessionRow <SessionRow
session={props.session} session={props.session}
@@ -203,28 +296,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
hasError={hasError} hasError={hasError}
unseenCount={unseenCount} unseenCount={unseenCount}
setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon} clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened} sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")} warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")} warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
/> />
) )
return ( return (
<>
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<div class="flex min-w-0 items-center gap-1"> <div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Show <Show
when={!tooltip()} when={hoverEnabled()}
fallback={ fallback={
<Tooltip <Tooltip
placement={props.mobile ? "bottom" : "right"} placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)} value={props.session.title}
gutter={10} gutter={10}
class="min-w-0 w-full" class="min-w-0 w-full"
> >
@@ -232,11 +327,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Tooltip> </Tooltip>
} }
> >
{item} <SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show> </Show>
</div> </div>
<Show when={!props.level}>
<div <div
class="shrink-0 overflow-hidden transition-[width,opacity]" class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{ classList={{
@@ -260,17 +374,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
/> />
</Tooltip> </Tooltip>
</div> </div>
</Show>
</div> </div>
</div> </div>
<Show when={currentChild()}>
{(child) => (
<div class="w-full">
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
</div>
)}
</Show>
</>
) )
} }
@@ -280,6 +385,7 @@ export const NewSessionItem = (props: {
dense?: boolean dense?: boolean
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
setHoverSession: (id: string | undefined) => void
}): JSX.Element => { }): JSX.Element => {
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
@@ -289,8 +395,9 @@ export const NewSessionItem = (props: {
<A <A
href={`/${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
@@ -1,4 +1,4 @@
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { displayName, sortedRootSessions } from "./helpers" import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
@@ -19,6 +19,7 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
nav: Accessor<HTMLElement | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
@@ -31,7 +32,8 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[] workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense"> sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
setHoverSession: (id: string | undefined) => void
} }
export const ProjectDragOverlay = (props: { export const ProjectDragOverlay = (props: {
@@ -53,6 +55,7 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: { const ProjectTile = (props: {
project: LocalProject project: LocalProject
mobile?: boolean mobile?: boolean
nav: Accessor<HTMLElement | undefined>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
selected: Accessor<boolean> selected: Accessor<boolean>
active: Accessor<boolean> active: Accessor<boolean>
@@ -192,7 +195,9 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]> workspaces: Accessor<string[]>
label: (directory: string) => string label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -213,8 +218,9 @@ const ProjectPreviewPanel = (props: {
list={props.projectSessions()} list={props.projectSessions()}
slug={base64Encode(props.project.worktree)} slug={base64Encode(props.project.worktree)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={props.projectChildren()}
/> />
)} )}
</For> </For>
@@ -223,6 +229,7 @@ const ProjectPreviewPanel = (props: {
<For each={props.workspaces()}> <For each={props.workspaces()}>
{(directory) => { {(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory)) const sessions = createMemo(() => props.workspaceSessions(directory))
const children = createMemo(() => props.workspaceChildren(directory))
return ( return (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
@@ -239,8 +246,9 @@ const ProjectPreviewPanel = (props: {
list={sessions()} list={sessions()}
slug={base64Encode(directory)} slug={base64Encode(directory)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={children()}
/> />
)} )}
</For> </For>
@@ -302,14 +310,20 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => { const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow()) return sortedRootSessions(data, props.sortNow())
} }
const workspaceChildren = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
const tile = () => ( const tile = () => (
<ProjectTile <ProjectTile
project={props.project} project={props.project}
mobile={props.mobile} mobile={props.mobile}
nav={props.ctx.nav}
sidebarHovering={props.ctx.sidebarHovering} sidebarHovering={props.ctx.sidebarHovering}
selected={selected} selected={selected}
active={active} active={active}
@@ -346,6 +360,7 @@ export const SortableProject = (props: {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
<ProjectPreviewPanel <ProjectPreviewPanel
@@ -356,7 +371,9 @@ export const SortableProject = (props: {
workspaces={workspaces} workspaces={workspaces}
label={label} label={label}
projectSessions={projectSessions} projectSessions={projectSessions}
projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions, workspaceKey } from "./helpers" import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -35,6 +35,9 @@ export type WorkspaceSidebarContext = {
navList: Accessor<Session[]> navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -149,6 +152,7 @@ const WorkspaceActions = (props: {
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string root: string
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void navigateToNewSession: () => void
}): JSX.Element => ( }): JSX.Element => (
@@ -222,6 +226,7 @@ const WorkspaceActions = (props: {
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.setHoverSession(undefined)
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
props.navigateToNewSession() props.navigateToNewSession()
}} }}
@@ -234,10 +239,12 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: { const WorkspaceSessionList = (props: {
slug: Accessor<string> slug: Accessor<string>
mobile?: boolean mobile?: boolean
popover?: boolean
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
showNew: Accessor<boolean> showNew: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
sessions: Accessor<Session[]> sessions: Accessor<Session[]>
children: Accessor<Map<string, string[]>>
hasMore: Accessor<boolean> hasMore: Accessor<boolean>
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
@@ -249,6 +256,7 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile} mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
setHoverSession={props.ctx.setHoverSession}
/> />
</Show> </Show>
<Show when={props.loading()}> <Show when={props.loading()}>
@@ -262,8 +270,13 @@ const WorkspaceSessionList = (props: {
navList={props.ctx.navList} navList={props.ctx.navList}
slug={props.slug()} slug={props.slug()}
mobile={props.mobile} mobile={props.mobile}
showChild popover={props.popover}
children={props.children()}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession} prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession} archiveSession={props.ctx.archiveSession}
@@ -294,6 +307,7 @@ export const SortableWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
@@ -307,6 +321,7 @@ export const SortableWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
@@ -413,6 +428,7 @@ export const SortableWorkspace = (props: {
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree} root={props.project.worktree}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)} navigateToNewSession={() => navigate(`/${slug()}/session`)}
/> />
@@ -424,10 +440,12 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
@@ -443,6 +461,7 @@ export const LocalWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
@@ -452,6 +471,7 @@ export const LocalWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0) const loading = createMemo(() => !booted() && count() === 0)
@@ -469,10 +489,12 @@ export const LocalWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
+79 -321
View File
@@ -1,4 +1,4 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { import {
@@ -14,7 +14,6 @@ import {
onMount, onMount,
untrack, untrack,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -58,19 +57,12 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string } const emptyFollowups: (FollowupDraft & { id: string })[] = []
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
sessionID: () => string | undefined sessionID: () => string | undefined
@@ -334,9 +326,10 @@ export default function Page() {
const { params, sessionKey, tabs, view } = useSessionLayout() const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => { untrack(() => {
if (params.id) return if (params.id || !prompt.ready()) return
const text = searchParams.prompt const text = searchParams.prompt
if (!text) return if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -351,7 +344,6 @@ export default function Page() {
scroll: { scroll: {
overflow: false, overflow: false,
bottom: true, bottom: true,
jump: false,
}, },
}) })
@@ -430,18 +422,16 @@ export default function Page() {
} }
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0)
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab, normalizeTab,
review: reviewTab, review: reviewTab,
hasReview: canReview, hasReview,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
@@ -464,6 +454,7 @@ export default function Page() {
if (!id) return false if (!id) return false
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages, emptyUserMessages,
@@ -516,46 +507,21 @@ export default function Page() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode, changes: "session" as "session" | "turn",
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false, deferRender: false,
}) })
const [vcs, setVcs] = createStore<{ const [followup, setFollowup] = createStore({
diff: { items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
git: VcsFileDiff[] failed: {} as Record<string, string | undefined>,
branch: VcsFileDiff[] paused: {} as Record<string, boolean | undefined>,
} edit: {} as Record<
ready: { string,
git: boolean { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
branch: boolean >,
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
branch: false,
},
}) })
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
paused: Record<string, boolean | undefined>
edit: Record<string, FollowupEdit | undefined>
}>({
items: {},
failed: {},
paused: {},
edit: {},
}),
)
createComputed((prev) => { createComputed((prev) => {
const key = sessionKey() const key = sessionKey()
if (key !== prev) { if (key !== prev) {
@@ -570,72 +536,8 @@ export default function Page() {
let reviewFrame: number | undefined let reviewFrame: number | undefined
let refreshFrame: number | undefined let refreshFrame: number | undefined
let refreshTimer: number | undefined let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined let diffFrame: number | undefined
let diffTimer: number | undefined let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => { createComputed((prev) => {
const open = desktopReviewOpen() const open = desktopReviewOpen()
@@ -650,37 +552,8 @@ export default function Page() {
return open return open
}, desktopReviewOpen()) }, desktopReviewOpen())
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs()
})
const reviewCount = createMemo(() => reviewDiffs().length)
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
return true
})
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
@@ -746,6 +619,19 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto") scrollToMessage(msgs[targetIndex], "auto")
} }
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
})
function upsert(next: Project) { function upsert(next: Project) {
const list = globalSync.data.project const list = globalSync.data.project
sync.set("project", next.id) sync.set("project", next.id)
@@ -824,6 +710,7 @@ export default function Page() {
if (!info) return true if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL return Date.now() - info.at > SESSION_PREFETCH_TTL
})() })()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => { untrack(() => {
void sync.session.sync(id) void sync.session.sync(id)
}) })
@@ -835,47 +722,13 @@ export default function Page() {
if (params.id !== id) return if (params.id !== id) return
untrack(() => { untrack(() => {
if (stale) void sync.session.sync(id, { force: true }) if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
}) })
}, 0) }, 0)
}) })
}), }),
) )
createEffect(
on(
() => {
const id = params.id
return [
sdk.directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect( createEffect(
on( on(
() => visibleUserMessages().at(-1)?.id, () => visibleUserMessages().at(-1)?.id,
@@ -893,46 +746,13 @@ export default function Page() {
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("changes", "git") setStore("changes", "session")
setUi("pendingMessage", undefined) setUi("pendingMessage", undefined)
}, },
{ defer: true }, { defer: true },
), ),
) )
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect( createEffect(
on( on(
() => params.dir, () => params.dir,
@@ -1049,46 +869,12 @@ export default function Page() {
} }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return if (composer.blocked()) return
inputRef?.focus() inputRef?.focus()
} }
} }
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -1118,10 +904,7 @@ export default function Page() {
setFileTreeTab("all") setFileTreeTab("all")
} }
const focusInput = () => { const focusInput = () => inputRef?.focus()
if (isChildSession()) return
inputRef?.focus()
}
useSessionCommands({ useSessionCommands({
navigateMessageByOffset, navigateMessageByOffset,
@@ -1138,22 +921,21 @@ export default function Page() {
loadFile: file.load, loadFile: file.load,
}) })
const changesTitle = () => { const changesOptions = ["session", "turn"] as const
if (!canReview()) { const changesOptionsList = [...changesOptions]
return null
}
const label = (option: ChangeMode) => { const changesTitle = () => {
if (option === "git") return language.t("ui.sessionReview.title.git") if (!hasReview()) {
if (option === "branch") return language.t("ui.sessionReview.title.branch") return null
return language.t("ui.sessionReview.title.lastTurn")
} }
return ( return (
<Select <Select
options={changesOptions()} options={changesOptionsList}
current={store.changes} current={store.changes}
label={label} label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)} onSelect={(option) => option && setStore("changes", option)}
variant="ghost" variant="ghost"
size="small" size="small"
@@ -1162,13 +944,21 @@ export default function Page() {
) )
} }
const empty = (text: string) => ( const emptyTurn = () => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text}</div> <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div> </div>
) )
const createGit = (input: { emptyClass: string }) => ( const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
if (hasReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div> <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
@@ -1183,27 +973,11 @@ export default function Page() {
</Button> </Button>
</div> </div>
) )
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
} }
return ( return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div> </div>
) )
} }
@@ -1229,9 +1003,6 @@ export default function Page() {
onLineCommentUpdate={updateCommentInContext} onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext} onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()} lineCommentActions={reviewCommentActions()}
commentMentions={{
items: file.searchFilesAndDirectories,
}}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus} onFocusedCommentChange={comments.setFocus}
@@ -1310,7 +1081,7 @@ export default function Page() {
const pending = tree.pendingDiff const pending = tree.pendingDiff
if (!pending) return if (!pending) return
if (!tree.reviewScroll) return if (!tree.reviewScroll) return
if (!reviewReady()) return if (!diffsReady()) return
const attempt = (count: number) => { const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return if (tree.pendingDiff !== pending) return
@@ -1351,7 +1122,10 @@ export default function Page() {
const id = params.id const id = params.id
if (!id) return if (!id) return
if (!wantsReview()) return const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return if (sync.status === "loading") return
@@ -1360,7 +1134,13 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => [sessionKey(), wantsReview()] as const, () =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
([key, wants]) => { ([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1421,17 +1201,13 @@ export default function Page() {
let scrollStateTarget: HTMLDivElement | undefined let scrollStateTarget: HTMLDivElement | undefined
let fillFrame: number | undefined let fillFrame: number | undefined
const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
const updateScrollState = (el: HTMLDivElement) => { const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight const max = el.scrollHeight - el.clientHeight
const distance = max - el.scrollTop
const overflow = max > 1 const overflow = max > 1
const bottom = !overflow || distance <= 2 const bottom = !overflow || el.scrollTop >= max - 2
const jump = overflow && distance > jumpThreshold(el)
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom, jump }) setUi("scroll", { overflow, bottom })
} }
const scheduleScrollState = (el: HTMLDivElement) => { const scheduleScrollState = (el: HTMLDivElement) => {
@@ -1645,7 +1421,7 @@ export default function Page() {
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
const id = params.id const id = params.id
if (!id) return false if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
}) })
const followupText = (item: FollowupDraft) => { const followupText = (item: FollowupDraft) => {
@@ -1677,7 +1453,6 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
@@ -1808,7 +1583,6 @@ export default function Page() {
if (followupBusy(sessionID)) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return if (composer.blocked()) return
if (busy(sessionID)) return if (busy(sessionID)) return
@@ -1858,25 +1632,15 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume, consumePendingMessage: layout.pendingMessage.consume,
}) })
createEffect(
on(
() => params.id,
(id) => {
if (!id) requestAnimationFrame(() => inputRef?.focus())
},
),
)
onMount(() => { onMount(() => {
makeEventListener(document, "keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1990,7 +1754,7 @@ export default function Page() {
}} }}
onResponseSubmit={resumeScroll} onResponseSubmit={resumeScroll}
followup={ followup={
params.id && !isChildSession() params.id
? { ? {
queue: queueEnabled, queue: queueEnabled,
items: followupDock(), items: followupDock(),
@@ -2042,12 +1806,6 @@ export default function Page() {
</div> </div>
<SessionSidePanel <SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel} reviewPanel={reviewPanel}
activeDiff={tree.activeDiff} activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff} focusReviewDiff={focusReviewDiff}
@@ -1,11 +1,9 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -15,7 +13,6 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit" import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@@ -45,17 +42,11 @@ export function SessionComposerRegion(props: {
} }
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
}) { }) {
const navigate = useNavigate()
const prompt = usePrompt() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const route = useSessionKey() const route = useSessionKey()
const sync = useSync()
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () => const previewPrompt = () =>
prompt prompt
@@ -121,18 +112,16 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height)) const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
}
createEffect(() => { createEffect(() => {
const el = store.body const el = store.body
if (!el) return if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height) const update = () => {
createResizeObserver(store.body, update) setStore("height", el.getBoundingClientRect().height)
}
update() update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
return ( return (
@@ -170,7 +159,7 @@ export function SessionComposerRegion(props: {
)} )}
</Show> </Show>
<Show when={showComposer()}> <Show when={!props.state.blocked()}>
<Show <Show
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
@@ -246,10 +235,6 @@ export function SessionComposerRegion(props: {
onEdit={props.followup!.onEdit} onEdit={props.followup!.onEdit}
/> />
</Show> </Show>
<Show
when={child()}
fallback={
<Show when={!props.state.blocked()}>
<PromptInput <PromptInput
ref={props.inputRef} ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree} newSessionWorktree={props.newSessionWorktree}
@@ -261,25 +246,6 @@ export function SessionComposerRegion(props: {
onAbort={props.followup?.onAbort} onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit} onSubmit={props.onSubmit}
/> />
</Show>
}
>
<div
ref={props.inputRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>
@@ -1,6 +1,5 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@@ -87,7 +86,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
pull() pull()
} }
makeEventListener(window, composerEvent, onEvent) window.addEventListener(composerEvent, onEvent)
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
}) })
const todos = createMemo((): Todo[] => { const todos = createMemo((): Todo[] => {
@@ -8,56 +8,9 @@ import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<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-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
ref?: (el: HTMLButtonElement) => void
onFocus?: VoidFunction
onClick: VoidFunction
}) {
return (
<button
type="button"
ref={props.ref}
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
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">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -72,30 +25,22 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
focus: 0,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false let replied = false
let focusFrame: number | undefined
const question = createMemo(() => questions()[store.tab]) const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? []) const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "") const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true) const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true) const multi = createMemo(() => question()?.multiple === true)
const count = createMemo(() => options().length + 1)
const summary = createMemo(() => { const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total()) const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() }) return language.t("session.question.progress", { current: n, total: total() })
}) })
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1) const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => { const customUpdate = (value: string, selected: boolean = on()) => {
@@ -140,29 +85,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
root.style.setProperty("--question-prompt-max-height", `${max}px`) root.style.setProperty("--question-prompt-max-height", `${max}px`)
} }
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
const pickFocus = (tab: number = store.tab) => {
const list = questions()[tab]?.options ?? []
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
)
}
const focus = (i: number) => {
const next = clamp(i)
setStore("focus", next)
if (store.editing) return
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
focusFrame = requestAnimationFrame(() => {
focusFrame = undefined
const el = next === options().length ? customRef : optsRef[next]
el?.focus()
})
}
onMount(() => { onMount(() => {
let raf: number | undefined let raf: number | undefined
const update = () => { const update = () => {
@@ -174,22 +96,22 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
update() update()
window.addEventListener("resize", update)
makeEventListener(window, "resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]') const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".scroll-view__viewport") const scroller = document.querySelector(".scroll-view__viewport")
createResizeObserver([dock, scroller], update) const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf) if (raf !== undefined) cancelAnimationFrame(raf)
}) })
focus(pickFocus())
}) })
onCleanup(() => { onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return if (replied) return
cache.set(props.request.id, { cache.set(props.request.id, {
tab: store.tab, tab: store.tab,
@@ -242,13 +164,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer) if (custom) setStore("custom", store.tab, answer)
@@ -265,7 +180,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customToggle = () => { const customToggle = () => {
if (sending()) return if (sending()) return
setStore("focus", options().length)
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -285,68 +199,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const value = input().trim() const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false) setStore("editing", false)
focus(options().length)
} }
const customOpen = () => { const customOpen = () => {
if (sending()) return if (sending()) return
setStore("focus", options().length)
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const move = (step: number) => {
if (store.editing || sending()) return
focus(store.focus + step)
}
const nav = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === "Escape") {
event.preventDefault()
void reject()
return
}
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
if (mod && event.key === "Enter") {
if (event.repeat) return
event.preventDefault()
next()
return
}
const target =
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
if (store.editing) 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)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Home") {
event.preventDefault()
focus(0)
return
}
if (event.key !== "End") return
event.preventDefault()
focus(count() - 1)
}
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (sending()) return if (sending()) return
@@ -358,7 +219,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const opt = options()[optIndex] const opt = options()[optIndex]
if (!opt) return if (!opt) return
if (multi()) { if (multi()) {
setStore("editing", false)
toggle(opt.label) toggle(opt.label)
return return
} }
@@ -368,25 +228,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const commitCustom = () => { const commitCustom = () => {
setStore("editing", false) setStore("editing", false)
customUpdate(input()) customUpdate(input())
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 = () => { const next = () => {
@@ -398,33 +239,27 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return return
} }
const tab = store.tab + 1 setStore("tab", store.tab + 1)
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
const back = () => { const back = () => {
if (sending()) return if (sending()) return
if (store.tab <= 0) return if (store.tab <= 0) return
const tab = store.tab - 1 setStore("tab", store.tab - 1)
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (sending()) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
return ( return (
<DockPrompt <DockPrompt
kind="question" kind="question"
ref={(el) => (root = el)} ref={(el) => (root = el)}
onKeyDown={nav}
header={ header={
<> <>
<div data-slot="question-header-title">{summary()}</div> <div data-slot="question-header-title">{summary()}</div>
@@ -435,7 +270,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button" type="button"
data-slot="question-progress-segment" data-slot="question-progress-segment"
data-active={i() === store.tab} data-active={i() === store.tab}
data-answered={answered(i())} data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={sending()} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -447,7 +285,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape"> <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
@@ -456,13 +294,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -475,39 +307,69 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show> </Show>
<div data-slot="question-options"> <div data-slot="question-options">
<For each={options()}> <For each={options()}>
{(opt, i) => ( {(opt, i) => {
<Option const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
multi={multi()} return (
picked={picked(opt.label)} <button
label={opt.label} data-slot="question-option"
description={opt.description} data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()} disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
/> >
)} <span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For> </For>
<Show <Show
when={store.editing} when={store.editing}
fallback={ fallback={
<button <button
type="button"
ref={customRef}
data-slot="question-option" data-slot="question-option"
data-custom="true" data-custom="true"
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={sending()} disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen} onClick={customOpen}
> >
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} /> <span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span> <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span> <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span> </span>
</button> </button>
} }
@@ -532,13 +394,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom() commitCustom()
}} }}
> >
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} /> <span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span> <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea <textarea
ref={focusCustom} ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input" data-slot="question-custom-input"
placeholder={customPlaceholder()} placeholder={language.t("ui.question.custom.placeholder")}
value={input()} value={input()}
rows={1} rows={1}
disabled={sending()} disabled={sending()}
@@ -546,17 +428,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
setStore("editing", false) setStore("editing", false)
focus(options().length)
return return
} }
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault() e.preventDefault()
commitCustom() commitCustom()
}} }}
onInput={(e) => { onInput={(e) => {
customUpdate(e.currentTarget.value) customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget) e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}} }}
/> />
</span> </span>
@@ -6,8 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer" import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -92,7 +91,9 @@ export function SessionTodoDock(props: {
setStore("height", el.getBoundingClientRect().height) setStore("height", el.getBoundingClientRect().height)
} }
update() update()
createResizeObserver(el, update) const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
createEffect(() => { createEffect(() => {
@@ -210,25 +211,76 @@ export function SessionTodoDock(props: {
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}} }}
> >
<TodoList todos={props.todos} /> <TodoList todos={props.todos} open={!store.collapsed} />
</div> </div>
</div> </div>
</DockTray> </DockTray>
) )
} }
function TodoList(props: { todos: Todo[] }) { function TodoList(props: { todos: Todo[]; open: boolean }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stuck: false, stuck: false,
scrolling: false,
})
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (store.scrolling) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStore("stuck", scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
}) })
return ( return (
<div class="relative"> <div class="relative">
<div <div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar" class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }} style={{ "overflow-anchor": "none" }}
onScroll={(e) => { onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0) setStore("stuck", e.currentTarget.scrollTop > 0)
setStore("scrolling", true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore("scrolling", false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}} }}
> >
<Index each={props.todos}> <Index each={props.todos}>
+134 -140
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file" import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -53,124 +52,6 @@ function FileCommentMenu(props: {
) )
} }
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
const [code, setCode] = createSignal<HTMLElement[]>([])
const getCode = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
const current = code()
if (next.length === current.length && next.every((el, i) => el === current[i])) return
setCode(next)
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code().length > 0) {
for (const item of code()) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code().length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code().length === 0) sync()
save({
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
createEffect(() => {
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
})
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) { export function FileTabContent(props: { tab: string }) {
const file = useFile() const file = useFile()
const comments = useComments() const comments = useComments()
@@ -184,6 +65,11 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab }).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null let find: FileSearchHandle | null = null
const search = { const search = {
@@ -206,10 +92,6 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
}) })
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => { const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, { return previewSelectedLines(source, {
@@ -218,12 +100,6 @@ export function FileTabContent(props: { tab: string }) {
}) })
} }
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: { const addCommentToContext = (input: {
file: string file: string
selection: SelectedLineRange selection: SelectedLineRange
@@ -232,7 +108,14 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file" origin?: "review" | "file"
}) => { }) => {
const selection = selectionFromLines(input.selection) const selection = selectionFromLines(input.selection)
const preview = input.preview ?? buildPreview(input.file, selection) const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({ const saved = comments.add({
file: input.file, file: input.file,
@@ -257,7 +140,8 @@ export function FileTabContent(props: { tab: string }) {
comment: string comment: string
}) => { }) => {
comments.update(input.file, input.id, input.comment) comments.update(input.file, input.id, input.comment)
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, { prompt.context.updateComment(input.file, input.id, {
comment: input.comment, comment: input.comment,
...(preview ? { preview } : {}), ...(preview ? { preview } : {}),
@@ -295,9 +179,6 @@ export function FileTabContent(props: { tab: string }) {
comments: fileComments, comments: fileComments,
label: language.t("ui.lineComment.submit"), label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab, draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
state: { state: {
opened: () => note.openedComment, opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id), setOpened: (id) => setNote("openedComment", id),
@@ -351,7 +232,8 @@ export function FileTabContent(props: { tab: string }) {
find?.focus() find?.focus()
} }
makeEventListener(window, "keydown", onKeyDown, { capture: true }) window.addEventListener("keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
}) })
createEffect( createEffect(
@@ -378,6 +260,102 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus()) requestAnimationFrame(() => comments.clearFocus())
}) })
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => { const cancelCommenting = () => {
const p = path() const p = path()
if (p) file.setSelectedLines(p, null) if (p) file.setSelectedLines(p, null)
@@ -397,7 +375,16 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active } prev = { loaded, ready, active }
if (!restore) return if (!restore) return
scrollSync.queueRestore() queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
const renderFile = (source: string) => ( const renderFile = (source: string) => (
@@ -415,7 +402,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()} selectedLines={activeSelection()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
onRendered={() => { onRendered={() => {
scrollSync.queueRestore() queueRestore()
}} }}
annotations={commentsUi.annotations()} annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation} renderAnnotation={commentsUi.renderAnnotation}
@@ -433,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto", mode: "auto",
path: path(), path: path(),
current: state()?.content, current: state()?.content,
onLoad: scrollSync.queueRestore, onLoad: queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => { onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return if (args.kind !== "svg") return
showToast({ showToast({
@@ -448,7 +435,14 @@ export function FileTabContent(props: { tab: string }) {
return ( return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full"> <Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}> <ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<Switch> <Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match> <Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
+8 -4
View File
@@ -1,6 +1,5 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same" import { same } from "@/utils/same"
const emptyTabs: string[] = [] const emptyTabs: string[] = []
@@ -172,9 +171,14 @@ export const createSizing = () => {
} }
onMount(() => { onMount(() => {
makeEventListener(window, "pointerup", stop) window.addEventListener("pointerup", stop)
makeEventListener(window, "pointercancel", stop) window.addEventListener("pointercancel", stop)
makeEventListener(window, "blur", stop) window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -21,7 +21,6 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -30,7 +29,6 @@ import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer" import { makeTimer } from "@solid-primitives/timer"
@@ -45,6 +43,7 @@ type MessageComment = {
const emptyMessages: MessageType[] = [] const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
type UserActions = { type UserActions = {
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -69,16 +68,6 @@ const messageComments = (parts: Part[]): MessageComment[] =>
] ]
}) })
const taskDescription = (part: Part, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]") const nested = current?.closest("[data-scrollable]")
@@ -211,7 +200,7 @@ export function MessageTimeline(props: {
mobileChanges: boolean mobileChanges: boolean
mobileFallback: JSX.Element mobileFallback: JSX.Element
actions?: UserActions actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean } scroll: { overflow: boolean; bottom: boolean }
onResumeScroll: () => void onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void setScrollRef: (el: HTMLDivElement | undefined) => void
onScheduleScrollState: (el: HTMLDivElement) => void onScheduleScrollState: (el: HTMLDivElement) => void
@@ -302,36 +291,9 @@ export function MessageTimeline(props: {
return sync.session.get(id) return sync.session.get(id)
}) })
const titleValue = createMemo(() => info()?.title) const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync.session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => sync.data.part[message.id] ?? [])
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID())) const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 } const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({ const staging = createTimelineStaging({
@@ -354,20 +316,8 @@ export function MessageTimeline(props: {
open: false, open: false,
dismiss: null as "escape" | "outside" | null, dismiss: null as "escape" | "outside" | null,
}) })
const [bar, setBar] = createStore({
ms: pace(640),
})
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
createResizeObserver(
() => head,
() => {
if (!head || head.clientWidth <= 0) return
setBar("ms", pace(head.clientWidth))
},
)
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
@@ -447,21 +397,9 @@ export function MessageTimeline(props: {
), ),
) )
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync.data.message[id] !== undefined) return
void sync.session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => { const openTitleEditor = () => {
if (!sessionID() || parentID()) return if (!sessionID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" }) setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => { requestAnimationFrame(() => {
titleRef?.focus() titleRef?.focus()
titleRef?.select() titleRef?.select()
@@ -479,7 +417,7 @@ export function MessageTimeline(props: {
if (titleMutation.isPending) return if (titleMutation.isPending) return
const next = title.draft.trim() const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) { if (!next || next === (titleValue() ?? "")) {
setTitle("editing", false) setTitle("editing", false)
return return
} }
@@ -594,9 +532,7 @@ export function MessageTimeline(props: {
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo( const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => { const handleDelete = async () => {
await deleteSession(props.sessionID) await deleteSession(props.sessionID)
dialog.close() dialog.close()
@@ -632,24 +568,17 @@ export function MessageTimeline(props: {
<div <div
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out" class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{ classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(), "opacity-100 translate-y-0 scale-100":
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
"opacity-0 translate-y-2 scale-95 pointer-events-none": "opacity-0 translate-y-2 scale-95 pointer-events-none":
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(), !props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
}} }}
> >
<button <button
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group" class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
onClick={props.onResumeScroll} onClick={props.onResumeScroll}
> >
<div <Icon name="arrow-down-to-line" />
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
style={{
"box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
}}
>
<Icon name="arrow-down-to-line" size="small" />
</div>
</button> </button>
</div> </div>
<ScrollView <ScrollView
@@ -707,53 +636,27 @@ export function MessageTimeline(props: {
<div ref={props.setContentRef} class="min-w-0 w-full"> <div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}> <Show when={showHeader()}>
<div <div
ref={(el) => {
head = el
setBar("ms", pace(el.clientWidth))
}}
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
relative: true,
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true, "pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={workingStatus() !== "hidden"}>
<div
data-component="session-progress"
data-state={workingStatus()}
aria-hidden="true"
style={{
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
"--session-progress-ms": `${bar.ms}ms`,
}}
>
<div data-component="session-progress-bar" />
</div>
</Show>
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<div class="flex items-center min-w-0 grow-1">
<Show when={parentID()}> <Show when={parentID()}>
<button <IconButton
type="button" tabIndex={-1}
data-slot="session-title-parent" icon="arrow-left"
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base" variant="ghost"
onClick={navigateParent} onClick={navigateParent}
> aria-label={language.t("common.goBack")}
{parentTitle()} />
</button>
<span
data-slot="session-title-separator"
class="px-2 text-14-medium text-text-weak"
aria-hidden="true"
>
/
</span>
</Show> </Show>
<div class="flex items-center min-w-0 grow-1">
<div <div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ style={{
@@ -771,16 +674,15 @@ export function MessageTimeline(props: {
</div> </div>
</Show> </Show>
</div> </div>
<Show when={childTitle() || title.editing}> <Show when={titleValue() || title.editing}>
<Show <Show
when={title.editing} when={title.editing}
fallback={ fallback={
<h1 <h1
data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0" class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor} onDblClick={openTitleEditor}
> >
{childTitle()} {titleValue()}
</h1> </h1>
} }
> >
@@ -788,7 +690,6 @@ export function MessageTimeline(props: {
ref={(el) => { ref={(el) => {
titleRef = el titleRef = el
}} }}
data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -816,7 +717,6 @@ export function MessageTimeline(props: {
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" /> <SessionContextUsage placement="bottom" />
<Show when={!parentID()}>
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
placement="bottom-end" placement="bottom-end"
@@ -988,7 +888,6 @@ export function MessageTimeline(props: {
</KobaltePopover.Content> </KobaltePopover.Content>
</KobaltePopover.Portal> </KobaltePopover.Portal>
</KobaltePopover> </KobaltePopover>
</Show>
</div> </div>
)} )}
</Show> </Show>
+15 -15
View File
@@ -1,6 +1,5 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
@@ -14,12 +13,10 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element title?: JSX.Element
empty?: JSX.Element empty?: JSX.Element
diffs: () => ReviewDiff[] diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void onDiffStyleChange?: (style: DiffStyle) => void
@@ -33,9 +30,6 @@ export interface SessionReviewTabProps {
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
focusedFile?: string focusedFile?: string
onScrollRef?: (el: HTMLDivElement) => void onScrollRef?: (el: HTMLDivElement) => void
commentMentions?: {
items: (query: string) => string[] | Promise<string[]>
}
classes?: { classes?: {
root?: string root?: string
header?: string header?: string
@@ -126,6 +120,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onCleanup(() => { onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scroll) {
scroll.removeEventListener("wheel", handleInteraction, { capture: true })
scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
scroll.removeEventListener("keydown", handleInteraction, { capture: true })
}
}) })
return ( return (
@@ -134,11 +135,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty} empty={props.empty}
scrollRef={(el) => { scrollRef={(el) => {
scroll = el scroll = el
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true }) el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true }) el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true }) el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true }) el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true }) el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
props.onScrollRef?.(el) props.onScrollRef?.(el)
queueRestore() queueRestore()
}} }}
@@ -161,7 +162,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onLineCommentUpdate={props.onLineCommentUpdate} onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete} onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions} lineCommentActions={props.lineCommentActions}
lineCommentMention={props.commentMentions}
comments={props.comments} comments={props.comments}
focusedComment={props.focusedComment} focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange} onFocusedCommentChange={props.onFocusedCommentChange}
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers" import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) => const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
({ ({
id: "msg", id: "msg",
sessionID: "session", sessionID: "session",
@@ -10,6 +10,7 @@ const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
time: { created: 1 }, time: { created: 1 },
agent: input?.agent ?? "build", agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" }, model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: input?.variant,
}) as UserMessage }) as UserMessage
describe("syncSessionModel", () => { describe("syncSessionModel", () => {
@@ -25,12 +26,10 @@ describe("syncSessionModel", () => {
reset() {}, reset() {},
}, },
}, },
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), message({ variant: "high" }),
) )
expect(calls).toEqual([ expect(calls).toEqual([message({ variant: "high" })])
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
])
}) })
}) })

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