mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
471 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f346024f0e | |||
| 56ea8a4a49 | |||
| efaeda00a1 | |||
| 08ea08e830 | |||
| 4c0feeebb4 | |||
| 4d314f6e04 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 | |||
| 339fd50cb9 | |||
| 828a4b3f15 | |||
| a43de3f86d | |||
| fe3b2e8f90 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 | |||
| 430750e2f8 | |||
| 7913c4a490 | |||
| 5414697bd1 | |||
| e22e3b8f2c | |||
| 8e657c7db5 | |||
| 02e2277057 | |||
| ac1ddc3f83 | |||
| 8f904c8e9a | |||
| ec07ee56f4 | |||
| de86d73d19 | |||
| 0fa9e5039e | |||
| c073387723 | |||
| 1956497f42 | |||
| 2a7e32c416 | |||
| 56a7c06a80 | |||
| 75e8fd4da2 | |||
| b9f39dd751 | |||
| 6eeeb4bfcf | |||
| 1ccaca826e | |||
| 04f9b15178 | |||
| 66b9cc7931 | |||
| e3f7637eb2 | |||
| 5945a8d429 | |||
| c7ceccf869 | |||
| 00ab94c44f | |||
| fe09a2e9b7 | |||
| 59202356fe | |||
| 637282f3b9 | |||
| d1550cb599 | |||
| ed6c117184 | |||
| 6ce62bd84a | |||
| a4a948316b | |||
| aca3b7813d | |||
| 0eaa75ec0f | |||
| 6b3c4f5839 | |||
| 8e76adb08f | |||
| 0bb24a46c1 | |||
| d3f4695af3 | |||
| 5b2715d24f | |||
| 20e37e7122 | |||
| b87bb4486f | |||
| feaec7a6be | |||
| 1c67004999 | |||
| 43ecf3ff1b | |||
| 9028c2d8f8 | |||
| be18f22842 | |||
| 6216dbae15 | |||
| 96a9731947 | |||
| 768a69bbbd | |||
| b7b941c354 | |||
| cf5e0adf02 | |||
| 41d0a9f010 | |||
| accaa792d8 | |||
| 76d7a11585 | |||
| c867e66ab5 | |||
| 278c510549 | |||
| 0d6ccd2a50 | |||
| 39cceeb143 | |||
| a6449cb45c | |||
| 6a85f0d3db | |||
| fbb0fdf88a | |||
| 2a4b298108 | |||
| efeff63749 | |||
| d54038b9d2 | |||
| 8f04a09d1a | |||
| 761f37370f | |||
| b452368b3b | |||
| 3785eddfa0 | |||
| 4a006b1210 | |||
| 2db7ccb453 | |||
| 629b304c48 | |||
| 1004fbb2f5 | |||
| a7746379d5 | |||
| a72992e00f | |||
| 99156c10e6 | |||
| f6f0a5c10f | |||
| e2eed76101 | |||
| 9ff7ef3fb0 | |||
| 8fe78c8f8d | |||
| bd0dffd781 | |||
| 0ac8f91ed6 | |||
| 4c80c23e11 | |||
| 33c0cc2bb6 | |||
| cc686ab8f6 | |||
| 6524dfc818 | |||
| c91604d519 | |||
| a2718177be | |||
| 6fb526741b | |||
| 85fdb3bde6 | |||
| b642f9c7bc | |||
| 855a9569b5 | |||
| aa0e626b12 | |||
| 389c866cac | |||
| 7feefb697f | |||
| d4155f2906 | |||
| 67f2393f83 | |||
| cccd013801 | |||
| be7a684791 | |||
| 984cab7938 | |||
| 7eea97184a | |||
| 0590819090 | |||
| 687dbba6a3 | |||
| 2347f4d7b4 | |||
| 7698a5e6ac | |||
| ec8eea7cbe | |||
| b44a981eef | |||
| 79415f625a | |||
| 19f42f7102 | |||
| 212c9f99ee | |||
| 99e52303e6 | |||
| 88925ec6e6 | |||
| cedf365674 | |||
| 5b39972947 | |||
| 09553d46e0 | |||
| e1d72fa338 | |||
| b3cf749a7f | |||
| dd842f9862 | |||
| 3dd29094f4 | |||
| 05e8d5be73 | |||
| 8b634e4a58 | |||
| c6156f171c | |||
| 4d2b06f8cf | |||
| bf15c97e4b | |||
| 3cbcd8d727 | |||
| cc29f86cdc | |||
| 86e3828da6 | |||
| 8d00a51766 | |||
| ed4f833813 | |||
| e96c24ce2e | |||
| ed6ad272ec | |||
| d27746e5b3 | |||
| eb661e6e6d | |||
| 3379e44abb | |||
| e1abf59c59 | |||
| 4f976bcf1a | |||
| e25a724bc2 | |||
| 521ea87192 | |||
| 3100701488 | |||
| 9c54291ae8 | |||
| 52ad916ba4 | |||
| 5db320b02d | |||
| 910e37f6d8 | |||
| 81f6e06681 | |||
| da68e2865e | |||
| f86e1cc1ff | |||
| 947bbf9490 | |||
| 3e57b43a4a | |||
| cb18a42a47 | |||
| 1fb2837fd3 | |||
| 66a8d830aa | |||
| d0733f83bf | |||
| b2ce195bda | |||
| 6c065ca6a5 | |||
| dd1c007877 | |||
| 8deb0e5780 | |||
| f488089179 | |||
| e3a39b214f | |||
| b6a2912bb1 | |||
| 93b7ca9b4d | |||
| fcb1d4b418 | |||
| 04b673432c | |||
| bd947658bb | |||
| bb6ee0a5cc | |||
| fd4497e6b9 | |||
| 59790380de | |||
| 7668eb728d | |||
| 79301fbed5 | |||
| c0ea1a43f9 | |||
| 96717c1a8c | |||
| e312f0d775 | |||
| bfdbf43ef8 | |||
| 984430c97d | |||
| bf01264661 | |||
| b046bbe4e3 | |||
| 8dc9ac07a2 | |||
| ba24037e64 | |||
| df471872de | |||
| 8942f97998 | |||
| 754dd57177 | |||
| f044def957 | |||
| 9c2842b9b0 | |||
| 9e0d3976e1 | |||
| f87998f37f | |||
| e57d9ca390 | |||
| d603eed5a1 | |||
| 0e1c0414c2 | |||
| dc99c600b6 | |||
| 6ffe61b9fe | |||
| 36d17c30a7 | |||
| 4d100de194 | |||
| c13f06c30c | |||
| 91f1815732 | |||
| 1a52e1118e | |||
| f2f62438b8 | |||
| 2830176972 | |||
| 06dcf3f221 | |||
| f9442e7107 | |||
| ff499c3603 | |||
| 4cee5bd824 | |||
| f406fed924 | |||
| 5b2d90a441 | |||
| 04e8801f5b | |||
| 7f008df79d | |||
| 175d200e53 | |||
| 41d11a8a89 | |||
| 0bba8c780a | |||
| 32cf36de9d | |||
| fb75ea2cf6 | |||
| 147da5d278 | |||
| eed23d8ee9 | |||
| e6e951b252 | |||
| 4e019ba5d9 | |||
| 50a762e7b9 | |||
| 780c99bc2e | |||
| 5bcf8d5a0b | |||
| 652e8ff3fb | |||
| 7b88de47c3 | |||
| 33cb536879 | |||
| 6ffecf9345 | |||
| 08741f6b93 | |||
| baacb2e776 | |||
| a75978815a | |||
| c3d26c4912 | |||
| a9b7bd9e2f | |||
| f9d1d3b259 | |||
| f950497173 | |||
| 391aa38281 | |||
| 29e8502bb0 | |||
| 44b182fe23 | |||
| 905123b9c0 | |||
| bb3b6a2f65 | |||
| b8efb33cde | |||
| d65ecd4a90 | |||
| 57fb3e5cc5 | |||
| ba07481b59 | |||
| b99759c7de | |||
| c590e27639 | |||
| 8f4b62eb49 | |||
| 945d1c8cb2 | |||
| 610e618bc5 | |||
| 9daa4d85a4 | |||
| 62af66a74f | |||
| 5b44e5bf41 | |||
| a15afbe8f2 | |||
| 8e0856c43b | |||
| f66c829231 | |||
| 3baaabede8 | |||
| afe3ebbc35 | |||
| e2faeb84e5 | |||
| 35ed09ff37 | |||
| 9751615651 | |||
| c9b24ef027 | |||
| b04d8d53e6 | |||
| 7bd3f8ac83 | |||
| 6ae2fa5196 | |||
| 650d774372 | |||
| 64e4f6f91b | |||
| d097cc8065 | |||
| 438654768c | |||
| 37b26e495b | |||
| 5d14c7a185 | |||
| e66cbf36e9 | |||
| 50977dd4fe | |||
| a644e0e7a0 | |||
| ca861fdf43 | |||
| 34a08cbdb8 | |||
| bf3ae45439 | |||
| 394e0b9045 | |||
| 4790a2772c | |||
| 2ff19171dc | |||
| 1401901529 | |||
| 1b88ff8d53 | |||
| 08293e74a0 | |||
| 24d26365e6 | |||
| c22793d5f6 | |||
| c6a52a39b5 | |||
| aad8d90dd1 | |||
| 6f47459c68 | |||
| dd768e30e2 | |||
| de476aa51b | |||
| bd8d858bf7 | |||
| 5fc4e18b82 | |||
| 12d9f4b29b | |||
| a2769b5ade | |||
| 1fa605ad5e | |||
| 698ef25f33 | |||
| 88dc960af8 | |||
| 5657cec4f2 | |||
| 8d790a9ed5 | |||
| b62d24e40b | |||
| a1b6c84135 | |||
| 85fde8c11d | |||
| c167bde6a0 | |||
| d9bf30fc22 | |||
| 3ce5e9800d | |||
| af5eabcb26 | |||
| 0087dd56d9 | |||
| ef2140d121 | |||
| 1de3c6e4a6 | |||
| b6c3a8ea7b | |||
| efcf2c3f5d | |||
| e65477ab1d | |||
| cd0b274856 | |||
| 57e9e9771d | |||
| 1aae92c42a | |||
| 6555df912a | |||
| 7ebd344fa2 | |||
| 460cdc5aec | |||
| b0ca5520a1 | |||
| bc2e270f82 | |||
| 33705e632a | |||
| cff2345c12 | |||
| 0405518180 | |||
| c9604c86ec | |||
| 967c44552e | |||
| ec95694e2f | |||
| c176baee82 | |||
| 016d296f7c | |||
| dbaa53329c | |||
| 7843f8fb38 | |||
| 4045041554 | |||
| 7ec7413fdb | |||
| f016392368 | |||
| 140224b0fc | |||
| cfd35c9354 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be | |||
| 041215996c | |||
| d0b920e187 | |||
| 8c94e9005f | |||
| 932a40cfd9 | |||
| 716f6658db | |||
| 9f1a8f2149 | |||
| b3650e2316 | |||
| fb884bb91e | |||
| 98c09884bf | |||
| 5e47501b8b | |||
| d51ba6ed94 | |||
| a6983b65fc | |||
| 1a06198954 | |||
| 72ec09cf74 | |||
| e2bca216a2 | |||
| a10733dbf4 | |||
| b5823d1077 | |||
| e1fafa2e54 | |||
| ae49b21376 | |||
| 5710064ae1 | |||
| 4617b03ca9 | |||
| 6a91a682e4 | |||
| f8626865b9 | |||
| 2324a63fc6 | |||
| 1ce607c230 | |||
| c26f6f95f7 | |||
| d77b87ea65 | |||
| 6ca6566bd3 | |||
| 8dd993d25a | |||
| 24ab17e718 | |||
| 0a60662d71 | |||
| 194b0615e0 | |||
| a5c51e11d0 | |||
| 8f1db7d06d | |||
| b1ca070b3b | |||
| 12887e572e | |||
| 75715e2115 | |||
| a1250cd690 | |||
| 23adaaaeab | |||
| 524ee8fc03 | |||
| 0f9719a7b5 | |||
| 1b83c08b8a | |||
| 02cb350880 | |||
| 4ce830a919 | |||
| 461a1c3ab4 | |||
| 6846542115 | |||
| f928b5be07 | |||
| ecfa918760 | |||
| f80624cf17 | |||
| fe59174c23 | |||
| 2fe057324f | |||
| 19a5b5a05d | |||
| ff4cab03c1 | |||
| b2d46ecd7e | |||
| 360d85a521 | |||
| c65a7d50c1 | |||
| fa73546a86 | |||
| 935ac2db91 | |||
| 01edae4a7f | |||
| 381d67572e | |||
| e8ac44430b | |||
| 5ae93092aa | |||
| 595c6bd4a7 | |||
| 7073e8797f | |||
| f7034a35a8 | |||
| 53b93b6991 | |||
| 6067019434 | |||
| 42a3cf9645 | |||
| 04c6bed240 | |||
| 94e3a29d2f | |||
| 41283933ff | |||
| 11bf8d8a42 | |||
| c22973ab9f | |||
| 612009e0f1 | |||
| a0a6c9545e | |||
| bceb6d0a9e | |||
| 49e7dc191f | |||
| f4720be08e | |||
| d1d7ebc2c6 | |||
| cd942d0669 | |||
| 4741e3ee6b | |||
| 4673bfbaa0 | |||
| b458dd8c63 | |||
| 7edd6c3a1d | |||
| f43f066741 | |||
| b6553d14e1 | |||
| ab0042a666 | |||
| 5df049d081 | |||
| 573ab9c24b | |||
| 6a16c41e8f | |||
| a491cbee64 | |||
| 658cbe9caf | |||
| 62b2bc39df | |||
| beb2c52c3f | |||
| 655adbf46e | |||
| ac2a78391f | |||
| 9a9bdaba95 | |||
| 8b682c42b6 | |||
| ad4f1c1018 | |||
| a0afb63ed0 | |||
| df9ecb8f6a | |||
| e6f660fecf |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
---
|
||||
|
||||
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||
@@ -1,4 +1,5 @@
|
||||
adamdotdevin
|
||||
arvsrn
|
||||
Brendonovich
|
||||
fwang
|
||||
Hona
|
||||
@@ -7,11 +8,14 @@ jayair
|
||||
jlongster
|
||||
kitlangton
|
||||
kommander
|
||||
ludvigrask
|
||||
MrMushrooooom
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
thdxr
|
||||
simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
starptech
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -31,6 +32,9 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -122,7 +126,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -221,7 +225,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -447,6 +451,7 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -74,13 +75,9 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -2,9 +2,9 @@ name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -13,6 +13,7 @@ tmp
|
||||
dist
|
||||
ts-dist
|
||||
.turbo
|
||||
.typecheck-profiles
|
||||
**/.serena
|
||||
.serena/
|
||||
**/.omo
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: opencode-drive
|
||||
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
|
||||
---
|
||||
|
||||
# OpenCode Drive
|
||||
|
||||
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
|
||||
|
||||
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
|
||||
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
|
||||
on changes).
|
||||
|
||||
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
|
||||
stops all processes, and cleans up all artifacts.
|
||||
|
||||
# Prepare The Environment
|
||||
|
||||
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
|
||||
|
||||
```bash
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
|
||||
|
||||
# Scripted usage
|
||||
|
||||
You can write scripts that walk through entire flows, and gives you full access to controlling
|
||||
the backend too. See examples of the script API at the bottom of this file.
|
||||
|
||||
After creating or editing a script, always typecheck it before running. Never skip this step:
|
||||
|
||||
```bash
|
||||
opencode-drive check ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
Run it by passing `--script` to start:
|
||||
|
||||
```bash
|
||||
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
|
||||
```
|
||||
|
||||
It will output information about the run, including paths to log files which you can read
|
||||
to inspect what happened. If you need to dig into failures that aren't clear, read those log
|
||||
files. If the script is unsuccessful, automatically fix the script and run it again.
|
||||
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
async setup({ fs, config }) {
|
||||
config.autoupdate = false
|
||||
await fs.writeFile("src/example.ts", "export const value = 1\n")
|
||||
},
|
||||
|
||||
async run({ ui, llm }) {
|
||||
await ui.submit("Open src/example.ts")
|
||||
await llm.send(llm.text("The file exports `value`."))
|
||||
await ui.waitFor("The file exports `value`.")
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` receives the current OpenCode config object, which starts from the
|
||||
default drive config unless the prepared instance already has one. When a script
|
||||
needs custom config, mutate this `config` parameter instead of generating and
|
||||
writing a new config object from scratch, so the script keeps the default
|
||||
provider/model settings unless it intentionally changes them.
|
||||
|
||||
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
|
||||
|
||||
Use `launch: "manual"` when the script needs to launch the server and every TUI
|
||||
itself (this is extremely rare, do not use this unless explicitly asked). In this
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
`await llm.send(...)` waits for the next request and resolves after OpenCode
|
||||
acknowledges its complete response. `llm.queue(...)` declares responses in
|
||||
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
|
||||
`finish`, and `disconnect`. A normal response receives `finish("stop")`
|
||||
automatically unless it yields or queues an explicit terminal event.
|
||||
|
||||
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
|
||||
15-character target varied by plus or minus 5 per chunk.
|
||||
|
||||
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
|
||||
delay between any two outputs.
|
||||
|
||||
Use `llm.serve` for an ongoing typed response generator:
|
||||
|
||||
```ts
|
||||
llm.serve(async function* (request, index) {
|
||||
yield llm.reasoning(`Handling request ${index + 1}`)
|
||||
yield llm.text(`Received ${request.id}`)
|
||||
yield llm.finish("stop")
|
||||
})
|
||||
```
|
||||
|
||||
The backend connection, response cleanup, cancellation, and recording
|
||||
completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
- `prune` removes artifact directories. These are always cleaned up after running a script
|
||||
successfully, but leftover on failed runs. Always call this if a script fails.
|
||||
|
||||
```bash
|
||||
opencode-drive prune --name demo
|
||||
|
||||
// --force cleans up all artifcat directories
|
||||
opencode-dirve prune --force
|
||||
```
|
||||
|
||||
# Live interaction usage
|
||||
|
||||
- Always give headless instances a unique `--name`. Visible instances may omit it.
|
||||
- A normal headless `start` detaches automatically and returns after the instance is ready.
|
||||
- Do not add `&`; the long-running owner already runs in the background.
|
||||
- Configure simulated model responses after startup when needed.
|
||||
- Send ordered UI commands with `send`.
|
||||
- Always stop the instance when finished.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Explain this project"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
## Send UI Commands
|
||||
|
||||
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
|
||||
- Combine typing and Enter in one command when submitting a prompt.
|
||||
- JSON-valued commands require one JSON argument.
|
||||
- Multiple command flags execute from left to right.
|
||||
|
||||
Commands:
|
||||
|
||||
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
|
||||
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
|
||||
- `--command.ui.enter` presses Enter. Arguments: none.
|
||||
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
|
||||
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
|
||||
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
|
||||
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
|
||||
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.arrow '{"direction":"down"}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.focus '{"target":12}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.click '{"target":12,"x":4,"y":1}'
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.matches '{"text":"OpenCode"}'
|
||||
```
|
||||
|
||||
To read the UI state and see information about interactable elements, use the `ui.state` command:
|
||||
|
||||
```bash
|
||||
opencode-drive send --name demo --command.ui.state
|
||||
```
|
||||
|
||||
## Configure LLM Responses
|
||||
|
||||
- `responses` controls what the LLM responds with
|
||||
- Only use this if you are wanting to reproduce an exact type of response
|
||||
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
|
||||
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
|
||||
- `--tools` limits generated tool calls to names offered by OpenCode.
|
||||
|
||||
```bash
|
||||
opencode-drive responses --name demo \
|
||||
--types text,reasoning,diff,tool \
|
||||
--tools write,apply_patch
|
||||
|
||||
opencode-drive responses --name demo \
|
||||
--types tool \
|
||||
--tools read,glob,grep
|
||||
```
|
||||
|
||||
## Inspect The UI
|
||||
|
||||
- `ui.state` prints focus and interactive element metadata as JSON.
|
||||
- `ui.matches` checks for literal, case-sensitive screen text.
|
||||
- `screenshot` prints the generated image path.
|
||||
|
||||
```bash
|
||||
opencode-drive screenshot --name demo
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `stop` waits for recording export and owner cleanup before returning.
|
||||
|
||||
```bash
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Record The UI
|
||||
|
||||
- Start with `--record` to capture a headless instance from its first rendered frame.
|
||||
- `stop` finishes the recording, exports an MP4, and prints its path.
|
||||
|
||||
```bash
|
||||
opencode-drive start --name demo --record
|
||||
|
||||
opencode-drive send --name demo \
|
||||
--command.ui.type '{"text":"Show me the current architecture"}' \
|
||||
--command.ui.enter
|
||||
|
||||
opencode-drive stop --name demo
|
||||
```
|
||||
|
||||
# Artifacts dir
|
||||
|
||||
- `dir` prints the artifact directory for the instance.
|
||||
|
||||
```bash
|
||||
opencode-drive dir --name demo
|
||||
```
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: sample-skill
|
||||
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Use when the user asks for a sample skill or skill template.
|
||||
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
|
||||
- Do not use for unrelated coding tasks just because a skill exists.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Confirm the specific outcome the user wants if the request is ambiguous.
|
||||
- Inspect the relevant files before changing anything.
|
||||
- Make the smallest correct change.
|
||||
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
|
||||
- Summarize the changed files and any required restart or reload step.
|
||||
|
||||
## Example Response Style
|
||||
|
||||
When this skill is relevant, keep responses direct and actionable:
|
||||
|
||||
```text
|
||||
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
|
||||
Restart opencode for the new skill to be discovered by future sessions.
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
@@ -18,12 +19,15 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
|
||||
|
||||
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||
|
||||
Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user.
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
@@ -150,12 +154,15 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
+100
-80
@@ -4,40 +4,50 @@ OpenCode sessions preserve durable conversational history while assembling the r
|
||||
|
||||
## Language
|
||||
|
||||
**System Context**:
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
**Model Context**:
|
||||
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||
_Avoid_: System Context
|
||||
|
||||
**Instructions**:
|
||||
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Context Source**:
|
||||
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
||||
**Instruction Source**:
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**System Context Registry**:
|
||||
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||
**InstructionEntry**:
|
||||
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||
|
||||
**Mid-Conversation System Message**:
|
||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
**Instruction Update**:
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Context Snapshot**:
|
||||
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Unavailable Context**:
|
||||
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
@@ -45,11 +55,24 @@ A durable user input accepted into the Session inbox but not yet included in **S
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
@@ -87,52 +110,49 @@ _Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
@@ -162,12 +182,12 @@ _Avoid_: Response envelope
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
@@ -175,27 +195,27 @@ _Avoid_: Response envelope
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
||||
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
||||
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
@@ -217,9 +237,9 @@ Before stabilizing the client API:
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
||||
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
|
||||
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
|
||||
console.log("Subscribing to session events...")
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||
// ~250 GB as of 2026-07.
|
||||
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
|
||||
+3
-1
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.25 vCPU",
|
||||
memory: "0.5 GB",
|
||||
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||
memory: "2 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
|
||||
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
|
||||
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
|
||||
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
|
||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -2,23 +2,30 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -46,6 +53,7 @@
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -94,6 +102,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -146,7 +157,7 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -20,7 +20,7 @@ const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
tool: "patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
|
||||
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
|
||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "apply_patch",
|
||||
tool: "patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
|
||||
@@ -131,7 +131,7 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "apply_patch"
|
||||
(tool === "patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const draftID = "draft_legacy_new_session"
|
||||
const directory = "C:/OpenCode/LegacyNewSession"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_legacy_new_session",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "legacy-new-session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
|
||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
})
|
||||
@@ -65,7 +65,7 @@ async function mockServers(page: Page) {
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
const projectID = "proj_review_open_file"
|
||||
const sessionID = "ses_review_open_file"
|
||||
const title = "Review open file"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "open-file-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [fileDiff("src/changed.ts")],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return [
|
||||
fileNode("README.md"),
|
||||
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||
]
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
findFiles: (input) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
await filter.fill("nested")
|
||||
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||
await expect(result).toBeVisible()
|
||||
const resultID = await result.getAttribute("id")
|
||||
expect(resultID).toBeTruthy()
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
})
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
path,
|
||||
absolute: `${directory}/${path}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string) {
|
||||
return {
|
||||
file,
|
||||
before: "before\n",
|
||||
after: "after\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewStatePersistence"
|
||||
const projectID = "proj_review_state_persistence"
|
||||
const sessionA = "ses_review_state_a"
|
||||
const sessionB = "ses_review_state_b"
|
||||
const titleA = "Alpha review state"
|
||||
const titleB = "Beta review state"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await selectFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
await switchSession(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
await page.getByRole("button", { name: file }).click()
|
||||
await expectSelectedFile(page, file)
|
||||
}
|
||||
|
||||
async function expectSelectedFile(page: Page, file: string) {
|
||||
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-state-persistence",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function diff(file: string) {
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -9,12 +9,19 @@ const title = "Review terminal stacked"
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
|
||||
fileDiff(
|
||||
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
|
||||
100,
|
||||
false,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||
let detailVersion = 1
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
@@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? branchDiffs
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -96,7 +114,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
|
||||
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
@@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
})
|
||||
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
await lazyDiff
|
||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||
await expect(preview).toContainText("after-1")
|
||||
detailVersion = 2
|
||||
events.push(statusEvent("busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
@@ -201,12 +243,21 @@ function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number) {
|
||||
function statusEvent(type: "busy" | "idle") {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
|
||||
return {
|
||||
file,
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
patch: loaded
|
||||
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
|
||||
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
|
||||
@@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
@@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
@@ -246,7 +244,7 @@ function editPart(id: string) {
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||
const shellID = "prt_shell_outline"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||
reducedMotion: true,
|
||||
deviceScaleFactor,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
const output = part.locator('[data-component="bash-output"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(output).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||
if (!output) throw new Error("Shell output is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const outputRect = output.getBoundingClientRect()
|
||||
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||
element.style.transform = "translateY(0.25px)"
|
||||
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||
const style = getComputedStyle(output)
|
||||
return {
|
||||
outputWidth: outputRect.width,
|
||||
outputHeight: outputRect.height,
|
||||
borderColor: style.borderTopColor,
|
||||
boxShadow: style.boxShadow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
const clipped = await row.evaluate((element) => {
|
||||
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(clipped).toBeCloseTo(0.49, 1)
|
||||
|
||||
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||
const edges = await captureCardEdges(page, output)
|
||||
|
||||
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||
expect(geometry.boxShadow).toBe("none")
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||
const patchID = "prt_patch_outline"
|
||||
const file = {
|
||||
filePath: "src/outline.ts",
|
||||
relativePath: "src/outline.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "const outline = false\n",
|
||||
after: "const outline = true\n",
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
if (!card) throw new Error("Patch card is unavailable")
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||
const bottom = element.getBoundingClientRect().bottom
|
||||
return {
|
||||
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||
clipMargin,
|
||||
cardWidth: cardRect.width,
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||
expect(edges.luminance.top).toBeLessThan(245)
|
||||
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||
expect(geometry.clipMargin).toBe("0.5px")
|
||||
})
|
||||
|
||||
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||
const secondUserID = "msg_outline_second_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_outline_second_assistant",
|
||||
parentID: secondUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||
})),
|
||||
)
|
||||
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||
})
|
||||
|
||||
async function captureCardEdges(page: Page, card: Locator) {
|
||||
const box = await card.boundingBox()
|
||||
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||
const screenshot = await page.screenshot()
|
||||
return page.evaluate(
|
||||
async ({ source, box, viewport }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
const scale = {
|
||||
x: image.naturalWidth / viewport.width,
|
||||
y: image.naturalHeight / viewport.height,
|
||||
}
|
||||
const rows = (candidates: number[]) => {
|
||||
const left = Math.floor((box.x + 8) * scale.x)
|
||||
const width = Math.floor((box.width - 16) * scale.x)
|
||||
return candidates.map((row) => {
|
||||
const pixels = context.getImageData(left, row, width, 1).data
|
||||
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||
return {
|
||||
luminance:
|
||||
indexes
|
||||
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||
.reduce((sum, value) => sum + value, 0) / width,
|
||||
magenta:
|
||||
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||
.length / width,
|
||||
}
|
||||
})
|
||||
}
|
||||
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||
const columns = new Uint32Array(image.naturalWidth)
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||
}
|
||||
const top = box.y * scale.y
|
||||
const bottom = (box.y + box.height) * scale.y
|
||||
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||
return {
|
||||
box,
|
||||
luminance: {
|
||||
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||
},
|
||||
magenta: {
|
||||
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
viewport,
|
||||
box,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
@@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
@@ -90,7 +88,7 @@ function questionInput() {
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ async function mockServer(page: Page) {
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
||||
@@ -120,7 +120,7 @@ function toolPart(
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
tool === "patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
@@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -17,11 +17,11 @@ export interface MockServerConfig {
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
@@ -66,6 +66,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
@@ -96,8 +105,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, message)
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.15",
|
||||
"version": "1.17.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -47,14 +47,17 @@
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
@@ -88,6 +91,7 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+68
-14
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -88,10 +90,14 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return <SessionPage />
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||
<SessionPage />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<TargetServerRoute>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<LegacyTargetSessionRedirect />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerRoute>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRedirect() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const sync = useServerSync()
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -136,6 +175,7 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>)
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
@@ -144,7 +184,14 @@ function DraftRoute() {
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
{(draft) => (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||
>
|
||||
<ResolvedDraftRoute draft={draft} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -542,7 +589,14 @@ function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={LegacyHome} />
|
||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
@@ -550,15 +604,15 @@ function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
function NewLayoutLegacySessionRedirect() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
},
|
||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||
load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||
load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
@@ -263,7 +263,7 @@ function createSessionEntries(props: {
|
||||
return props
|
||||
.load(directory)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
result.items
|
||||
.filter((session) => !!session?.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
@@ -176,6 +177,7 @@ function ProviderConnection(props: {
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const alive = { value: true }
|
||||
const connected = { value: false }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -188,7 +190,16 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
const integrationID = () => {
|
||||
const value = provider()
|
||||
if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
|
||||
return value.integrationID
|
||||
}
|
||||
const location = () => {
|
||||
const directory = props.directory?.()
|
||||
return directory ? { location: { directory } } : {}
|
||||
}
|
||||
const fallback = createMemo<AuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
@@ -197,19 +208,52 @@ function ProviderConnection(props: {
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v2") {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integration = await capability.get({ ...location(), integrationID: integrationID() })
|
||||
if (!alive.value) return fallback()
|
||||
return (
|
||||
integration?.methods.flatMap((method): AuthMethod[] => {
|
||||
if (method.type === "environment") return []
|
||||
if (method.type === "key") return [{ type: "api", label: method.label }]
|
||||
return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
|
||||
}) ?? fallback()
|
||||
)
|
||||
}
|
||||
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
const result = await capability.methods(location())
|
||||
if (!alive.value) return fallback()
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(result).map(([id, methods]) => [
|
||||
id,
|
||||
methods.map((method) => ({
|
||||
...method,
|
||||
prompts: method.prompts?.map((prompt) =>
|
||||
prompt.type === "select"
|
||||
? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
|
||||
: { ...prompt },
|
||||
),
|
||||
})),
|
||||
]),
|
||||
)
|
||||
serverSync().set("provider_auth", normalized)
|
||||
return normalized[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const methods = createMemo<AuthMethod[]>(() => [
|
||||
...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
|
||||
])
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
authorization: undefined as undefined | ProviderAuthorization,
|
||||
attemptID: undefined as string | undefined,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
@@ -221,7 +265,7 @@ function ProviderConnection(props: {
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
@@ -230,6 +274,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.attemptID = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
@@ -238,6 +283,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.attemptID = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
@@ -262,6 +308,7 @@ function ProviderConnection(props: {
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.attemptID = action.attemptID
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
@@ -273,6 +320,16 @@ function ProviderConnection(props: {
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
onCleanup(() => {
|
||||
if (!store.attemptID || connected.value) return
|
||||
void (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
await backend.capabilities.integrationsV2
|
||||
?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
|
||||
.catch(() => undefined)
|
||||
})()
|
||||
})
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||
@@ -322,15 +379,33 @@ function ProviderConnection(props: {
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const backend = await serverSDK().backend
|
||||
const request =
|
||||
backend.version === "v1"
|
||||
? (() => {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
|
||||
})()
|
||||
: (() => {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!method.id) throw new Error("Provider OAuth method is missing an ID")
|
||||
return capability
|
||||
.connectOauth({
|
||||
...location(),
|
||||
integrationID: integrationID(),
|
||||
methodID: method.id,
|
||||
values: inputs ?? {},
|
||||
})
|
||||
.then((attempt) => ({
|
||||
url: attempt.url,
|
||||
method: attempt.mode,
|
||||
instructions: attempt.instructions,
|
||||
attemptID: attempt.attemptID,
|
||||
}))
|
||||
})()
|
||||
await request
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
const elapsed = Date.now() - start
|
||||
@@ -341,11 +416,19 @@ function ProviderConnection(props: {
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({
|
||||
type: "auth.complete",
|
||||
authorization: x,
|
||||
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||
})
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({
|
||||
type: "auth.complete",
|
||||
authorization: x,
|
||||
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
@@ -445,7 +528,7 @@ function ProviderConnection(props: {
|
||||
<div>
|
||||
<List
|
||||
class="px-3"
|
||||
items={select()?.options ?? []}
|
||||
items={[...(select()?.options ?? [])]}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
@@ -498,7 +581,10 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK().client.global.dispose()
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
connected.value = true
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -570,14 +656,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
|
||||
} else {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
await capability.connectKey({
|
||||
...location(),
|
||||
integrationID: integrationID(),
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
await complete()
|
||||
}
|
||||
|
||||
@@ -642,13 +734,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
const result = await (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||
await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
|
||||
})()
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
@@ -695,12 +794,26 @@ function ProviderConnection(props: {
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
const result = await (async () => {
|
||||
const backend = await serverSDK().backend
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.callback({ providerID: props.provider, method: store.methodIndex! })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||
while (alive.value) {
|
||||
const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
|
||||
if (status.status === "complete") return
|
||||
if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
|
||||
if (status.status === "expired") throw new Error("Authorization expired")
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
})()
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
@@ -118,7 +118,7 @@ export function CustomProviderForm() {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||
disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
|
||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
@@ -131,23 +131,26 @@ export function CustomProviderForm() {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const disabledProviders = serverSync().data.config.disabledProviders ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
const backend = await serverSDK().backend
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
if (result.key && backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.setApiKey({ providerID: result.providerID, key: result.key })
|
||||
}
|
||||
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
disabledProviders: nextDisabled,
|
||||
})
|
||||
if (result.key && backend.version === "v2") {
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
await capability.connectKey({ integrationID: result.providerID, key: result.key })
|
||||
await serverSync().refreshProviders()
|
||||
}
|
||||
return result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
@@ -177,7 +180,7 @@ export function CustomProviderForm() {
|
||||
return (
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverSDK().client.project.update({
|
||||
const editing = (await serverSDK().backend).capabilities.projectEditing
|
||||
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||
await editing.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
location: { directory: props.project.worktree },
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppPart } from "@/context/backend"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
const textPart = parts.find(
|
||||
(x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
)
|
||||
if (!textPart) continue
|
||||
|
||||
result.push({
|
||||
@@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.backend.then((client) => {
|
||||
const capability = client.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Session forking is not supported by this server")
|
||||
return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
|
||||
})
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() =>
|
||||
sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
sdk.backend
|
||||
.then((client) => client.capabilities.pathInfo?.get())
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
@@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
fallbackPath()?.home ||
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || typed === current) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
const files = await sdk.backend
|
||||
.then((client) =>
|
||||
client.common.files.find({
|
||||
location: { directory: root() },
|
||||
query: pickerFileSearchQuery(root(), value, home()),
|
||||
type: "file",
|
||||
limit: 20,
|
||||
}),
|
||||
)
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||
...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
return sdk.backend
|
||||
.then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
|
||||
.then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
|
||||
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
return sdk.backend
|
||||
.then((client) => client.capabilities.pathInfo?.get())
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
@@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
)
|
||||
|
||||
const directories = createDirectorySearch({
|
||||
sdk,
|
||||
backend: sdk.backend,
|
||||
home,
|
||||
base: start,
|
||||
})
|
||||
|
||||
@@ -20,14 +20,11 @@ import {
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
|
||||
|
||||
const DialogSelectFileV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
const DialogCommandPaletteV2 = lazy(() =>
|
||||
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
|
||||
)
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
|
||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||
const currentKey = createMemo(() => {
|
||||
const c = model.current()
|
||||
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
// Focus starts on the dialog's close button, outside the list, so listen at the
|
||||
// document level while the dialog is mounted instead of on the list container.
|
||||
let listEl: HTMLDivElement | undefined
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||
if (!listEl) return
|
||||
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
|
||||
if (buttons.length === 0) return
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next =
|
||||
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
|
||||
buttons[(next + buttons.length) % buttons.length]?.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
||||
<DialogHeader closeLabel={language.t("common.close")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||
<ScrollView class="min-h-0 flex-1 w-full">
|
||||
<div ref={listEl} class="flex min-h-full flex-col">
|
||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
</div>
|
||||
<For each={model.list()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||
</Show>
|
||||
<Show when={currentKey() === modelKey(item)}>
|
||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<For
|
||||
each={[...providers.popular()].sort((a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})}
|
||||
>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "opencode-go"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "anthropic"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.anthropic.note")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders()}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="dot-grid" size="small" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
@@ -474,7 +474,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
pickerRoot,
|
||||
pickerAbsoluteInput,
|
||||
} from "./directory-picker-domain"
|
||||
import { createAppClient } from "@/context/backend.test-fixture"
|
||||
|
||||
test("maps server directory entries into Pierre paths", () => {
|
||||
expect(
|
||||
@@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
return Promise.resolve({ data: [] })
|
||||
const backend = Promise.resolve(
|
||||
createAppClient({
|
||||
common: {
|
||||
files: {
|
||||
find: (input) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve([])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
}),
|
||||
)
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
|
||||
|
||||
await search("components")
|
||||
base = "/repo/src"
|
||||
|
||||
@@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
|
||||
}
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
import type { AppClient } from "@/context/backend"
|
||||
|
||||
export function cleanPickerInput(value: string) {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
@@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
||||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
export function createDirectorySearch(args: {
|
||||
backend: Promise<AppClient>
|
||||
base: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
@@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const key = trimPickerPath(directory)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
const request = args.backend
|
||||
.then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
.map((node) => ({
|
||||
name: node.name ?? getFilename(node.path),
|
||||
absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
|
||||
})),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
@@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
const results = await args.backend
|
||||
.then((client) =>
|
||||
client.common.files.find({
|
||||
location: { directory: input.directory },
|
||||
query,
|
||||
type: "directory",
|
||||
limit: 50,
|
||||
}),
|
||||
)
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
|
||||
}
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function FileTree(props: {
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
@@ -440,6 +441,7 @@ export default function FileTree(props: {
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
onFileDoubleClick={props.onFileDoubleClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
@@ -462,6 +464,7 @@ export default function FileTree(props: {
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
onDblClick={() => props.onFileDoubleClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
|
||||
@@ -1,54 +1,144 @@
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-slot="icon-svg"
|
||||
>
|
||||
<path
|
||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||
fill="var(--v2-icon-icon-base)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const triggerClass =
|
||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs="button"
|
||||
triggerProps={{
|
||||
type: "button",
|
||||
"aria-label": "Help",
|
||||
class:
|
||||
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
|
||||
}}
|
||||
trigger={<span aria-hidden="true">?</span>}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={8}
|
||||
placement="top-end"
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
|
||||
onClick={() => {
|
||||
setShown(false)
|
||||
setState("dismissed", true)
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
|
||||
<p class="text-12-regular text-text-weak">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover>
|
||||
</div>
|
||||
</Show>
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
aria-label="Open the OpenCode website"
|
||||
data-component="icon-button-v2"
|
||||
data-size="large"
|
||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
platform.openLink(event.currentTarget.href)
|
||||
}}
|
||||
>
|
||||
{helpIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={!state.dismissed}>
|
||||
<div
|
||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. A faster, more intuitive way to work."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={() => setState("dismissed", true)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
setState("dismissed", true)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={introducingTabsVideo}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loop
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
A faster, more intuitive way to work.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
June 16
|
||||
</p>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
Introducing Tabs Navigation.
|
||||
</p>
|
||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
||||
sessions.
|
||||
</p>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -104,94 +102,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -206,12 +116,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
@@ -43,6 +45,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -70,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppReference as ReferenceInfo } from "@/context/backend"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -520,7 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
setStore("popover", null)
|
||||
setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
requestAnimationFrame(() => editorRef?.focus())
|
||||
}
|
||||
|
||||
@@ -554,7 +558,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
])
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
|
||||
|
||||
const resetHistoryNavigation = (force = false) => {
|
||||
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||
@@ -674,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
client: resource.server,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
@@ -800,17 +804,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
const menu = store.slashMenu
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
if (menu) {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, 0)
|
||||
addPart({ type: "text", content: text, start: 0, end: text.length })
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
if (menu) {
|
||||
command.trigger(cmd.id, "slash")
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
@@ -1072,10 +1089,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (atMatch) {
|
||||
atOnInput(atMatch[1])
|
||||
setStore("popover", "at")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
} else if (slashMatch) {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
} else {
|
||||
closePopover()
|
||||
}
|
||||
@@ -1171,6 +1188,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const openCommands = () => {
|
||||
const populated = prompt.dirty() || commentCount() > 0
|
||||
requestAnimationFrame(() => {
|
||||
if (!populated) {
|
||||
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
return
|
||||
}
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const openContext = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
|
||||
atOnInput("")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
@@ -1199,7 +1238,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
setStore("mode", "normal")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
@@ -1286,13 +1325,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) return closePopover()
|
||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1325,7 +1368,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
if (cursorPosition === 0) {
|
||||
setStore("mode", "shell")
|
||||
setStore("popover", null)
|
||||
closePopover()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1460,6 +1503,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closePopover()
|
||||
requestAnimationFrame(() => editorRef.focus())
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
selectPopoverActive()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
||||
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
|
||||
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
|
||||
if (!nav && !ctrlNav) return
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
@@ -1488,9 +1554,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
})
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1527,6 +1595,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
slashActive={slashActive() ?? undefined}
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
slashMenu={store.slashMenu}
|
||||
slashMenuQuery={store.slashMenuQuery}
|
||||
onSlashMenuInput={(value) => {
|
||||
setStore("slashMenuQuery", value)
|
||||
slashOnInput(value)
|
||||
}}
|
||||
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
@@ -1625,23 +1700,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
{language.t("prompt.menu.addImagesAndFiles")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon={<IconV2 name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
style={buttons()}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||
{language.t("prompt.menu.imagesAndFiles")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={openCommands} shortcut="/">
|
||||
{language.t("prompt.menu.commands")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={openContext} shortcut="@">
|
||||
{language.t("prompt.menu.context")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
|
||||
{language.t("prompt.menu.shellCommand")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
@@ -1682,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="truncate leading-5">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
@@ -1969,11 +2063,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
})
|
||||
dialog.show(() => (
|
||||
<DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
}}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
@@ -2140,30 +2232,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
|
||||
).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
|
||||
expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppPart as Part, PromptPart } from "@/context/backend"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
type PromptRequestPart = PromptPart
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
|
||||
@@ -41,6 +41,10 @@ type PromptPopoverProps = {
|
||||
slashActive?: string
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
slashMenu: boolean
|
||||
slashMenuQuery: string
|
||||
onSlashMenuInput: (value: string) => void
|
||||
onSlashMenuKeyDown: (event: KeyboardEvent) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show when={props.slashMenu}>
|
||||
<div class="px-2 py-1">
|
||||
<input
|
||||
ref={(el) => requestAnimationFrame(() => el.focus())}
|
||||
value={props.slashMenuQuery}
|
||||
onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
|
||||
onKeyDown={props.onSlashMenuKeyDown}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
aria-label={props.t("prompt.menu.commands")}
|
||||
placeholder="/"
|
||||
class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
@@ -33,6 +34,10 @@ const prompt = {
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
@@ -76,6 +81,34 @@ const clientFor = (directory: string) => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
const backend = Promise.resolve({
|
||||
common: {
|
||||
sessions: {
|
||||
create: async (input: { location?: { directory?: string } }) => {
|
||||
const directory = input.location?.directory ?? "/repo/main"
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
title: `New session ${createdSessions.length}`,
|
||||
cost: 0,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
},
|
||||
prompt: async () => undefined,
|
||||
command: async () => undefined,
|
||||
interrupt: async () => undefined,
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sessionExtrasV1: {
|
||||
shell: async (input: { location?: { directory?: string } }) => {
|
||||
sentShell.push(input.location?.directory ?? "/repo/main")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
@@ -84,13 +117,6 @@ beforeAll(async () => {
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
@@ -156,6 +182,7 @@ beforeAll(async () => {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
backend,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
@@ -277,7 +304,7 @@ describe("prompt submit worktree selection", () => {
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
@@ -378,6 +405,39 @@ describe("prompt submit worktree selection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
@@ -403,7 +463,13 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
title: "New session 1",
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
}),
|
||||
])
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
@@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -39,13 +39,14 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: DirectorySDK["client"]
|
||||
backend: Promise<AppClient>
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
before?: () => Promise<boolean> | boolean
|
||||
commitRevert?: boolean
|
||||
}
|
||||
|
||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
@@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const backend = await input.backend
|
||||
const location = { directory: input.draft.sessionDirectory }
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
if (input.commitRevert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||
const capability = backend.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Commands are not supported by this server")
|
||||
await capability.command({
|
||||
location,
|
||||
sessionID: input.draft.sessionID,
|
||||
id: input.messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
model: {
|
||||
providerID: input.draft.model.providerID,
|
||||
id: input.draft.model.modelID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
const message: Message = {
|
||||
const message: AppMessage = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
role: "user",
|
||||
@@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
if (input.commitRevert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||
await backend.common.sessions.prompt({
|
||||
location,
|
||||
sessionID: input.draft.sessionID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
id: messageID,
|
||||
text,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
selection: {
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
providerID: input.draft.model.providerID,
|
||||
id: input.draft.model.modelID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
@@ -191,6 +210,7 @@ type PromptSubmitInput = {
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -221,8 +241,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
@@ -234,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.backend.then((client) =>
|
||||
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -263,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const seed = (dir: string, info: AppSession) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
setStore("session", (list: AppSession[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
@@ -298,9 +316,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentModel = local.model.current()
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = local.model.variant.current()
|
||||
const variant = modelSelection.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
@@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const backend = await sdk().backend
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
const createdWorktree = await backend.capabilities.worktreesV1
|
||||
?.create({ location: { directory: projectDirectory } })
|
||||
.catch((err: unknown) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
@@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
@@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
const created = await sdk()
|
||||
.backend.then((backend) =>
|
||||
backend.common.sessions.create({ location: { directory: sessionDirectory } }),
|
||||
)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -374,13 +389,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
@@ -440,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
sdk()
|
||||
.backend.then(async (backend) => {
|
||||
const location = { directory: sessionDirectory }
|
||||
if (session.revert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||
if (backend.capabilities.sessionExtrasV1)
|
||||
return backend.capabilities.sessionExtrasV1.shell({
|
||||
location,
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID },
|
||||
command: text,
|
||||
})
|
||||
if (backend.capabilities.sessionExtrasV2?.shell)
|
||||
return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
|
||||
throw new Error("Shell prompts are not supported by this server")
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -463,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
sdk()
|
||||
.backend.then(async (backend) => {
|
||||
const location = { directory: sessionDirectory }
|
||||
if (session.revert)
|
||||
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||
const capability = backend.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Commands are not supported by this server")
|
||||
return capability.command({
|
||||
location,
|
||||
sessionID: session.id,
|
||||
id: Identifier.ascending("message"),
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -563,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
backend: sdk().backend,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
commitRevert: !!session.revert,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PromptHistoryEntry } from "./history"
|
||||
|
||||
export type PromptInputTransientState = {
|
||||
popover: "at" | "slash" | null
|
||||
slashMenu: boolean
|
||||
slashMenuQuery: string
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
@@ -16,6 +18,8 @@ export type PromptInputTransientState = {
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
setStore({
|
||||
popover: null,
|
||||
slashMenu: false,
|
||||
slashMenuQuery: "",
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
draggingType: null,
|
||||
@@ -28,6 +32,8 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
||||
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
|
||||
const [store, setStore] = createStore<PromptInputTransientState>({
|
||||
popover: null,
|
||||
slashMenu: false,
|
||||
slashMenuQuery: "",
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
@@ -127,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage, AppPart } from "@/context/backend"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
@@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||
|
||||
const charsFromUserPart = (part: Part) => {
|
||||
const charsFromUserPart = (part: AppPart) => {
|
||||
if (part.type === "text") return part.text.length
|
||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||
return 0
|
||||
}
|
||||
|
||||
const charsFromAssistantPart = (part: Part) => {
|
||||
const charsFromAssistantPart = (part: AppPart) => {
|
||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||
@@ -68,8 +68,8 @@ const build = (
|
||||
}
|
||||
|
||||
export function estimateSessionContextBreakdown(args: {
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[] | undefined>
|
||||
messages: AppMessage[]
|
||||
parts: Record<string, AppPart[] | undefined>
|
||||
input: number
|
||||
systemPrompt?: string
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import type { AppMessage as Message } from "@/context/backend"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -38,10 +38,10 @@ const user = (id: string) => {
|
||||
}
|
||||
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.total).toBe(500)
|
||||
expect(ctx?.input).toBe(300)
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
|
||||
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
|
||||
test("computes stored session token totals", () => {
|
||||
expect(
|
||||
getSessionTokenTotal({
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 30,
|
||||
cache: { read: 40, write: 50 },
|
||||
}),
|
||||
).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppAssistantMessage, AppMessage } from "@/context/backend"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
@@ -14,21 +14,22 @@ type Model = {
|
||||
}
|
||||
|
||||
type Context = {
|
||||
message: AssistantMessage
|
||||
message: AppAssistantMessage
|
||||
provider?: Provider
|
||||
model?: Model
|
||||
providerLabel: string
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
const tokenTotal = (msg: AppAssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
|
||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
const lastAssistantWithTokens = (messages: AppMessage[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
@@ -37,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return undefined
|
||||
|
||||
@@ -54,15 +55,11 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||
if (!tokens) return undefined
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -135,7 +135,6 @@ export function SessionContextTab() {
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -204,14 +203,15 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -175,11 +176,7 @@ export function SessionHeader() {
|
||||
return LINUX_APPS
|
||||
})
|
||||
|
||||
const fileManager = createMemo(() => {
|
||||
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||
})
|
||||
const fileManager = createMemo(() => fileManagerApp(os()))
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||
<Show
|
||||
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
||||
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
|
||||
{getFilename(props.path)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||
export function SortableTab(props: {
|
||||
tab: string
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
}): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
return <FileVisual path={value} />
|
||||
return <FileVisual path={value} temporary={props.temporary} />
|
||||
})
|
||||
return (
|
||||
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
</Tabs.Trigger>
|
||||
|
||||
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
|
||||
<MenuV2.Context.Trigger class="relative" as="div">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
onMouseDown={(e) => {
|
||||
// Switch on mousedown to shave the press-release delay off tab switches.
|
||||
if (e.button !== 0) return
|
||||
if (store.editing) return
|
||||
focus()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (e.detail > 0) return
|
||||
focus()
|
||||
}}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export function useSettingsDialog() {
|
||||
export function useSettingsDialog(defaultValue?: string) {
|
||||
const dialog = useDialog()
|
||||
const params = useParams<{ id?: string }>()
|
||||
let run = 0
|
||||
@@ -19,7 +19,7 @@ export function useSettingsDialog() {
|
||||
const sessionID = params.id
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
if (dead || run !== current) return
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||
if (!capability) return []
|
||||
return capability.list().catch(() => [] as ShellOption[])
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
import { credentialConnectionIDs } from "@/context/backend"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabledProviders ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabledProviders", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.updateConfig({ disabledProviders: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabledProviders", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
const disconnect = async (item: ProviderItem) => {
|
||||
const backend = await serverSDK().backend
|
||||
const remove = async () => {
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.remove({ providerID: item.id })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integrationID =
|
||||
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||
const integration = await capability.get({ integrationID })
|
||||
await Promise.all(
|
||||
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||
capability.removeCredential({ credentialID }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfigCustom(item.id)) {
|
||||
await remove().catch(() => undefined)
|
||||
await disableProvider(item.id, item.name)
|
||||
return
|
||||
}
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
await remove()
|
||||
.then(async () => {
|
||||
await serverSDK().client.global.dispose()
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
@@ -224,7 +243,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
|
||||
@@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||
if (!capability) return []
|
||||
return capability.list().catch(() => [] as ShellOption[])
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
import { credentialConnectionIDs } from "@/context/backend"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabledProviders ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabledProviders", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.updateConfig({ disabledProviders: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabledProviders", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
const disconnect = async (item: ProviderItem) => {
|
||||
const backend = await serverSdk().backend
|
||||
const remove = async () => {
|
||||
if (backend.version === "v1") {
|
||||
const capability = backend.capabilities.providerAuthV1
|
||||
if (!capability) throw new Error("Server does not support provider authentication")
|
||||
await capability.remove({ providerID: item.id })
|
||||
return
|
||||
}
|
||||
const capability = backend.capabilities.integrationsV2
|
||||
if (!capability) throw new Error("Server does not support provider integrations")
|
||||
const integrationID =
|
||||
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||
const integration = await capability.get({ integrationID })
|
||||
await Promise.all(
|
||||
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||
capability.removeCredential({ credentialID }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfigCustom(item.id)) {
|
||||
await remove().catch(() => undefined)
|
||||
await disableProvider(item.id, item.name)
|
||||
return
|
||||
}
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
await remove()
|
||||
.then(async () => {
|
||||
await serverSdk().client.global.dispose()
|
||||
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
|
||||
{language.t("common.disconnect")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
@@ -221,7 +240,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
id="session.synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
|
||||
@@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
||||
<span class="settings-v2-servers-meta">
|
||||
<Show when={health()?.version}>v{health()?.version}</Show>
|
||||
<Show when={health()?.version && item.type === "http"}> • </Show>
|
||||
<Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
|
||||
<Show when={health()?.installationVersion && item.type === "http"}> • </Show>
|
||||
<Show
|
||||
when={item.type === "http" && item.http.username}
|
||||
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.settings-v2-panel::-webkit-scrollbar {
|
||||
@@ -181,6 +186,7 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer > span {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
|
||||
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
@@ -10,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
@@ -68,6 +67,19 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
|
||||
let current = tokens[key]
|
||||
for (let i = 0; i < 8 && current; i++) {
|
||||
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
|
||||
if (!match) {
|
||||
const hex = current.trim()
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
|
||||
return hex
|
||||
}
|
||||
current = tokens[match[1]]
|
||||
}
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -160,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||
const connection = useServerSDK()().server
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const url = sdk().url
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
const backend = sdk().backend
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
@@ -219,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return client.pty
|
||||
.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
return backend
|
||||
.then((client) =>
|
||||
client.common.pty.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
location: { directory },
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to sync terminal size", err)
|
||||
})
|
||||
@@ -238,7 +245,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (!variant?.seeds && !variant?.palette) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
const background = settings.general.newLayoutDesigns()
|
||||
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
|
||||
fallback.background)
|
||||
: (resolved["background-stronger"] ?? fallback.background)
|
||||
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||
const selectionBackground = withAlpha(base, alpha)
|
||||
@@ -473,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
backend
|
||||
.then((client) => {
|
||||
const transport = client.capabilities.ptyTransport
|
||||
if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
|
||||
return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
|
||||
})
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
|
||||
const connectToken = async () => {
|
||||
const result = await client.pty
|
||||
.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
const transport = (await backend).capabilities.ptyTransport
|
||||
if (!transport) return
|
||||
const result = await transport
|
||||
.connectToken({ ptyID: id, location: { directory } })
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
throw err
|
||||
})
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
if (result.status === 200 && result.ticket) return result.ticket
|
||||
if (result.status === 404 || result.status === 405) return
|
||||
if (result.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
throw new Error(`PTY connect ticket failed with ${result.status}`)
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
@@ -532,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const transport = (await backend).capabilities.ptyTransport
|
||||
if (!transport) {
|
||||
fail(new Error("PTY transport is not supported by this server"))
|
||||
return
|
||||
}
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken,
|
||||
}),
|
||||
transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { AppSession } from "@/context/backend"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
session: () => AppSession | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
@@ -120,8 +120,10 @@ export function TabNavItem(props: {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
const client = await ctx.sdk.backend
|
||||
const capability = client.capabilities.sessionActionsV1
|
||||
if (!capability) throw new Error("Session renaming is not supported by this server")
|
||||
await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
@@ -266,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
sdk.backend
|
||||
.then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
@@ -324,13 +324,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
const sessionTab = {
|
||||
type: "session" as const,
|
||||
server: route.server ?? server.key,
|
||||
sessionId: activeSession.id,
|
||||
}
|
||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
const activeTab = currentTab()
|
||||
if (activeTab?.type === "draft") {
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Taken from https://www.solid-ui.com/docs/components/drawer
|
||||
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
|
||||
*/
|
||||
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
|
||||
import DrawerPrimitive from "@corvu/drawer"
|
||||
|
||||
const Drawer = DrawerPrimitive
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
|
||||
|
||||
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
|
||||
const drawerContext = DrawerPrimitive.useContext()
|
||||
const overlayStyle = () => {
|
||||
const state = drawerContext.transitionState()
|
||||
if (state === "opening" || state === "closing") return undefined
|
||||
const open = drawerContext.openPercentage()
|
||||
return {
|
||||
opacity: open,
|
||||
"backdrop-filter": `blur(${4 * open}px)`,
|
||||
}
|
||||
}
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
class={props.class}
|
||||
classList={{
|
||||
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
|
||||
}}
|
||||
style={overlayStyle()}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
|
||||
class?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
class={props.class}
|
||||
classList={{
|
||||
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
|
||||
}
|
||||
|
||||
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
|
||||
}
|
||||
|
||||
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
|
||||
|
||||
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Label
|
||||
class={props.class}
|
||||
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
class={props.class}
|
||||
classList={{
|
||||
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
import { backendIdentity, createBackendForServer } from "./backend-client"
|
||||
|
||||
const server = {
|
||||
type: "http" as const,
|
||||
http: {
|
||||
url: "http://localhost:4096",
|
||||
username: "user",
|
||||
password: "secret",
|
||||
},
|
||||
authToken: false,
|
||||
}
|
||||
|
||||
function setup(health: ServerHealth) {
|
||||
const requests: Request[] = []
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (new URL(request.url).pathname === "/project")
|
||||
return new Response("[]", { headers: { "content-type": "application/json" } })
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
return {
|
||||
requests,
|
||||
fetch,
|
||||
backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
|
||||
}
|
||||
}
|
||||
|
||||
describe("createBackendForServer", () => {
|
||||
test("changes backend identity when credentials for the same URL change", () => {
|
||||
expect(backendIdentity(server)).not.toBe(
|
||||
backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("selects v2 and configures fetch and authentication", async () => {
|
||||
const result = setup({ healthy: true, version: "v2" })
|
||||
const backend = await result.backend
|
||||
|
||||
expect(backend.version).toBe("v2")
|
||||
expect(result.requests).toHaveLength(0)
|
||||
await backend.common.health.get()
|
||||
expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
|
||||
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
|
||||
expect(backend.capabilities.projectList).toBeUndefined()
|
||||
expect(backend.capabilities.vcs).toBeUndefined()
|
||||
expect(backend.capabilities.mcp).toBeUndefined()
|
||||
expect(result.requests).toHaveLength(1)
|
||||
expect(backend.version).toBe("v2")
|
||||
})
|
||||
|
||||
test("selects v1 after fallback detection", async () => {
|
||||
const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
|
||||
const backend = await result.backend
|
||||
|
||||
expect(backend.version).toBe("v1")
|
||||
expect(result.requests).toHaveLength(0)
|
||||
await backend.common.health.get()
|
||||
expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
|
||||
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
expect(backend.capabilities.projectList).toBeDefined()
|
||||
expect(backend.capabilities.vcs).toBeDefined()
|
||||
expect(backend.capabilities.mcp).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
import { authTokenFromCredentials } from "@/utils/server"
|
||||
import type { ServerConnection } from "./server"
|
||||
import type { LocationRef } from "./backend"
|
||||
import { createV1Backend } from "./backend-v1"
|
||||
import { createV2Backend } from "./backend-v2"
|
||||
|
||||
function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return {
|
||||
baseUrl: server.url,
|
||||
fetch,
|
||||
headers: server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return createOpencodeClient(options(server, fetch))
|
||||
}
|
||||
|
||||
export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||
return OpenCode.make(options(server, fetch))
|
||||
}
|
||||
|
||||
export function backendIdentity(server: ServerConnection.Any) {
|
||||
return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
|
||||
server.type === "http" && server.authToken === true ? "token" : ""
|
||||
}`
|
||||
}
|
||||
|
||||
export async function createBackendForServer(input: {
|
||||
server: ServerConnection.Any
|
||||
browserUrl: string
|
||||
fetch: typeof globalThis.fetch
|
||||
eventFetch?: typeof globalThis.fetch
|
||||
health: Promise<ServerHealth>
|
||||
defaultLocation?: LocationRef
|
||||
}) {
|
||||
const health = await input.health
|
||||
const eventFetch = input.eventFetch ?? input.fetch
|
||||
const transport = {
|
||||
baseUrl: input.server.http.url,
|
||||
fetch: input.fetch,
|
||||
username: input.server.http.username,
|
||||
password: input.server.http.password,
|
||||
sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
|
||||
authToken: input.server.type === "http" && input.server.authToken === true,
|
||||
}
|
||||
if (health.version === "v2")
|
||||
return createV2Backend(
|
||||
createV2RawClient(input.server.http, input.fetch),
|
||||
transport,
|
||||
input.defaultLocation,
|
||||
createV2RawClient(input.server.http, eventFetch),
|
||||
)
|
||||
const legacy = createV1RawClient(input.server.http, input.fetch)
|
||||
const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
|
||||
return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||
import type { PtyTransportConfig } from "./backend"
|
||||
import { createV1Backend } from "./backend-v1"
|
||||
|
||||
function setup(
|
||||
respond: (request: Request) => Response | Promise<Response>,
|
||||
withDefault = false,
|
||||
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return respond(request)
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
|
||||
return {
|
||||
requests,
|
||||
backend: createV1Backend(
|
||||
client,
|
||||
withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
|
||||
client,
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
fetch,
|
||||
username: "user",
|
||||
password: "secret",
|
||||
sameOrigin: transport?.sameOrigin ?? false,
|
||||
authToken: transport?.authToken ?? false,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown, headers?: HeadersInit) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
|
||||
})
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
|
||||
describe("createV1Backend", () => {
|
||||
test("preserves location in OAuth callbacks", async () => {
|
||||
const setupResult = setup(() => json({}))
|
||||
|
||||
await setupResult.backend.capabilities.providerAuthV1?.callback({
|
||||
providerID: "provider",
|
||||
method: 1,
|
||||
code: "code",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.searchParams.get("directory")).toBe("/repo")
|
||||
expect(url.searchParams.get("workspace")).toBe("workspace")
|
||||
})
|
||||
|
||||
test("normalizes session pagination and location", async () => {
|
||||
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
roots: true,
|
||||
limit: 10,
|
||||
cursor: "123",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
version: "1",
|
||||
parentID: undefined,
|
||||
projectID: "project",
|
||||
location: { directory: "/repo", workspaceID: undefined },
|
||||
directory: "/repo",
|
||||
workspaceID: undefined,
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: undefined,
|
||||
time: { created: 1, updated: 2 },
|
||||
share: undefined,
|
||||
revert: undefined,
|
||||
},
|
||||
],
|
||||
older: "456",
|
||||
})
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/experimental/session")
|
||||
expect(url.searchParams.get("directory")).toBe("/repo")
|
||||
expect(url.searchParams.get("workspace")).toBe("workspace")
|
||||
expect(url.searchParams.get("roots")).toBe("true")
|
||||
expect(url.searchParams.get("cursor")).toBe("123")
|
||||
})
|
||||
|
||||
test("converts normalized prompts to legacy parts", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: {
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
},
|
||||
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }],
|
||||
agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }],
|
||||
})
|
||||
|
||||
const request = setupResult.requests[0]
|
||||
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||
expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
|
||||
expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
|
||||
expect(await request.json()).toEqual({
|
||||
messageID: "msg_1",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
agent: "build",
|
||||
variant: "high",
|
||||
parts: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" },
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves ordered prompt part IDs and metadata", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "visible",
|
||||
parts: [
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
],
|
||||
})
|
||||
|
||||
expect((await setupResult.requests[0].json()).parts).toEqual([
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("combines mixed file search and decodes binary content", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/find/file") {
|
||||
return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"])
|
||||
}
|
||||
return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" })
|
||||
})
|
||||
|
||||
const found = await setupResult.backend.common.files.find({ query: "a" })
|
||||
const content = await setupResult.backend.common.files.read({ path: "a.bin" })
|
||||
|
||||
expect(found).toEqual([
|
||||
{ path: "a.txt", type: "file" },
|
||||
{ path: "shared", type: "directory" },
|
||||
{ path: "dir", type: "directory" },
|
||||
])
|
||||
expect([...content.bytes]).toEqual([0, 1, 2])
|
||||
expect(content.kind).toBe("binary")
|
||||
expect(content.mimeType).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("preserves project, provider, and file metadata", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/project")
|
||||
return json([
|
||||
{ id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
|
||||
])
|
||||
if (path === "/provider")
|
||||
return json({
|
||||
all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
|
||||
connected: [],
|
||||
default: {},
|
||||
})
|
||||
return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||
})
|
||||
|
||||
const projectList = setupResult.backend.capabilities.projectList
|
||||
if (!projectList) throw new Error("Missing project list capability")
|
||||
const projects = await projectList.list()
|
||||
const providers = await setupResult.backend.common.catalog.providers()
|
||||
const files = await setupResult.backend.common.files.list({})
|
||||
|
||||
expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
|
||||
expect(providers.providers.get("provider")?.source).toBe("config")
|
||||
expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||
})
|
||||
|
||||
test("uses explicit session locations and preserves mutation confirmations", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
|
||||
if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
|
||||
return json(session)
|
||||
}, true)
|
||||
|
||||
const history = await setupResult.backend.common.sessions.history({
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
})
|
||||
const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
|
||||
const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
})
|
||||
const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
|
||||
const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
|
||||
const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
|
||||
|
||||
expect(history.older).toBe("older")
|
||||
expect(removed).toBe(true)
|
||||
expect(reverted?.id).toBe("ses_1")
|
||||
expect(cleared?.id).toBe("ses_1")
|
||||
expect(worktreeRemoved).toBe(true)
|
||||
expect(worktreeReset).toBe(true)
|
||||
const urls = setupResult.requests.map((request) => new URL(request.url))
|
||||
expect(urls[0].searchParams.get("directory")).toBe("/explicit")
|
||||
expect(urls[1].searchParams.get("directory")).toBe("/default")
|
||||
})
|
||||
|
||||
test("normalizes idle and compatibility events", async () => {
|
||||
const events = [
|
||||
{ type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
|
||||
{
|
||||
type: "todo.updated",
|
||||
properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||
},
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
|
||||
},
|
||||
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||
{ type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
|
||||
{ type: "lsp.updated", properties: {} },
|
||||
{ type: "reference.updated", properties: {} },
|
||||
{ type: "mcp.tools.changed", properties: { server: "docs" } },
|
||||
{ type: "server.instance.disposed", properties: { directory: "/repo" } },
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
const result = await Promise.all(events.map(() => iterator.next()))
|
||||
|
||||
expect(result.map((item) => item.value?.event.type)).toEqual([
|
||||
"session.activity",
|
||||
"todo.updated",
|
||||
"timeline.delta",
|
||||
"timeline.part.removed",
|
||||
"worktree.ready",
|
||||
"lsp.updated",
|
||||
"reference.updated",
|
||||
"mcp.updated",
|
||||
"instance.disposed",
|
||||
])
|
||||
expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
|
||||
expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
|
||||
})
|
||||
|
||||
test("updates the V1 projection cache for deltas and removals", async () => {
|
||||
const info = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "p", modelID: "m" },
|
||||
}
|
||||
const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
|
||||
const events = [
|
||||
{ type: "message.updated", properties: { info } },
|
||||
{ type: "message.part.updated", properties: { sessionID: "ses_1", part } },
|
||||
{ type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
|
||||
{ type: "message.updated", properties: { info } },
|
||||
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||
{ type: "message.updated", properties: { info } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
const afterDelta = await iterator.next()
|
||||
await iterator.next()
|
||||
const afterRemoval = await iterator.next()
|
||||
|
||||
expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
|
||||
expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("merges global config updates with untouched fields", async () => {
|
||||
const bodies: unknown[] = []
|
||||
const setupResult = setup(async (request) => {
|
||||
if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] })
|
||||
bodies.push(await request.json())
|
||||
return json({})
|
||||
})
|
||||
|
||||
await setupResult.backend.capabilities.configuration?.updateGlobal({
|
||||
model: "new",
|
||||
disabledProviders: ["two"],
|
||||
})
|
||||
|
||||
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
|
||||
})
|
||||
|
||||
test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
|
||||
if (request.method === "GET") return new Response(null, { status: 404 })
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
|
||||
await setupResult.backend.common.permissions.reply({
|
||||
sessionID: "ses_1",
|
||||
requestID: "per_1",
|
||||
reply: "once",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
await setupResult.backend.common.questions.reject({
|
||||
sessionID: "ses_1",
|
||||
requestID: "que_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const transport = setupResult.backend.capabilities.ptyTransport
|
||||
const ticket = await transport?.connectToken({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const exists = await transport?.exists({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
expect(ticket).toEqual({ status: 405, ticket: undefined })
|
||||
expect(exists).toBe(false)
|
||||
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
"/explicit",
|
||||
])
|
||||
expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
|
||||
|
||||
const fallback = transport?.connectURL({
|
||||
ptyID: "pty/1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
cursor: 12,
|
||||
})
|
||||
expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
|
||||
expect(fallback?.protocol).toBe("ws:")
|
||||
expect(fallback?.searchParams.get("directory")).toBe("/explicit")
|
||||
expect(fallback?.searchParams.get("workspace")).toBe("workspace")
|
||||
expect(fallback?.searchParams.get("cursor")).toBe("12")
|
||||
expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||
|
||||
const ticketURL = transport?.connectURL({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit" },
|
||||
cursor: -1,
|
||||
ticket: "ticket value",
|
||||
})
|
||||
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves same-origin auth-token policy for PTY URLs", () => {
|
||||
const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
|
||||
const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
|
||||
.ptyTransport
|
||||
const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
|
||||
|
||||
expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
|
||||
expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,745 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
|
||||
import { createV2Backend } from "./backend-v2"
|
||||
|
||||
function setup(
|
||||
respond: (request: Request) => Response | Promise<Response>,
|
||||
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return respond(request)
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
|
||||
return {
|
||||
requests,
|
||||
backend: createV2Backend(
|
||||
client,
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
fetch,
|
||||
username: "user",
|
||||
password: transport && "password" in transport ? transport.password : "secret",
|
||||
sameOrigin: transport?.sameOrigin ?? false,
|
||||
authToken: transport?.authToken ?? false,
|
||||
},
|
||||
{ directory: "/default", workspaceID: "default-workspace" },
|
||||
client,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown) {
|
||||
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_1",
|
||||
projectID: "project",
|
||||
cost: 1.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
time: { created: 10, updated: 20 },
|
||||
title: "Session",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
}
|
||||
|
||||
describe("createV2Backend", () => {
|
||||
test("uses no legacy endpoints for bootstrap and common reads", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/health") return json({ healthy: true, version: "v2" })
|
||||
if (path === "/api/location")
|
||||
return json({
|
||||
directory: "/default",
|
||||
workspaceID: "default-workspace",
|
||||
project: { id: "project", directory: "/default" },
|
||||
})
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
setupResult.backend.common.health.get(),
|
||||
setupResult.backend.common.projects.current(),
|
||||
setupResult.backend.common.catalog.providers(),
|
||||
setupResult.backend.common.catalog.agents(),
|
||||
setupResult.backend.common.commands.list(),
|
||||
setupResult.backend.common.references.list(),
|
||||
setupResult.backend.common.files.list({}),
|
||||
setupResult.backend.common.files.find({ query: "src" }),
|
||||
setupResult.backend.common.permissions.pending(),
|
||||
setupResult.backend.common.questions.pending(),
|
||||
setupResult.backend.common.pty.list(),
|
||||
])
|
||||
|
||||
const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
|
||||
expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
|
||||
expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes session pages and preserves location precedence", async () => {
|
||||
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({
|
||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||
limit: 10,
|
||||
cursor: "cursor",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "ses_1",
|
||||
slug: "ses_1",
|
||||
version: "",
|
||||
parentID: undefined,
|
||||
projectID: "project",
|
||||
location: { directory: "/repo", workspaceID: "workspace" },
|
||||
directory: "/repo",
|
||||
workspaceID: "workspace",
|
||||
title: "Session",
|
||||
cost: 1.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
time: { created: 10, updated: 20 },
|
||||
revert: undefined,
|
||||
},
|
||||
],
|
||||
newer: "before",
|
||||
older: "after",
|
||||
})
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/api/session")
|
||||
expect(url.searchParams.get("directory")).toBe("/explicit")
|
||||
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
|
||||
expect(url.searchParams.has("parentID")).toBe(false)
|
||||
expect(url.searchParams.get("cursor")).toBe("cursor")
|
||||
})
|
||||
|
||||
test("paginates native sessions until roots:true contains only the requested roots", async () => {
|
||||
const child = { ...session, id: "child", parentID: "root_1" }
|
||||
const root1 = { ...session, id: "root_1" }
|
||||
const root2 = { ...session, id: "root_2" }
|
||||
const setupResult = setup((request) => {
|
||||
const cursor = new URL(request.url).searchParams.get("cursor")
|
||||
if (!cursor) return json({ data: [child], cursor: { next: "one" } })
|
||||
if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
|
||||
return json({ data: [root2], cursor: { next: "three" } })
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
|
||||
|
||||
expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
|
||||
expect(result.older).toBe("three")
|
||||
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
|
||||
})
|
||||
|
||||
test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
|
||||
return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
await setupResult.backend.common.files.list({ path: "dir" })
|
||||
const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
|
||||
|
||||
const url = new URL(setupResult.requests[0].url)
|
||||
expect(url.pathname).toBe("/api/fs/list")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/default")
|
||||
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||
expect(content).toEqual({
|
||||
bytes: Uint8Array.from([0, 1, 255]),
|
||||
kind: "binary",
|
||||
mimeType: "application/octet-stream",
|
||||
})
|
||||
const readURL = new URL(setupResult.requests[1].url)
|
||||
expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
|
||||
expect(readURL.searchParams.get("location[directory]")).toBe("/default")
|
||||
expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||
expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
|
||||
expect(setupResult.backend.version).toBe("v2")
|
||||
expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
|
||||
"integrationsV2",
|
||||
"projectCopiesV2",
|
||||
"ptyTransport",
|
||||
"savedPermissionsV2",
|
||||
"sessionExtrasV2",
|
||||
])
|
||||
expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.projectList).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.vcs).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.mcp).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
|
||||
expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
|
||||
)
|
||||
const transport = setupResult.backend.capabilities.ptyTransport
|
||||
|
||||
const ticket = await transport?.connectToken({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
const exists = await transport?.exists({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
})
|
||||
|
||||
expect(ticket).toEqual({ status: 403, ticket: undefined })
|
||||
expect(exists).toBe(false)
|
||||
expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
|
||||
const tokenURL = new URL(setupResult.requests[0].url)
|
||||
const existsURL = new URL(setupResult.requests[1].url)
|
||||
expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
|
||||
expect(existsURL.pathname).toBe("/api/pty/pty_1")
|
||||
expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
|
||||
expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
|
||||
expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||
|
||||
expect(() =>
|
||||
transport?.connectURL({
|
||||
ptyID: "pty/1",
|
||||
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||
cursor: 8,
|
||||
}),
|
||||
).toThrow("require a ticket")
|
||||
|
||||
const ticketURL = transport?.connectURL({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/explicit" },
|
||||
cursor: 0,
|
||||
ticket: "ticket value",
|
||||
})
|
||||
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
|
||||
test("allows ticketless same-origin native PTY URLs without credential queries", () => {
|
||||
const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
|
||||
.ptyTransport
|
||||
const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
|
||||
|
||||
expect(url?.searchParams.has("auth_token")).toBe(false)
|
||||
expect(url?.pathname).toBe("/api/pty/pty_1/connect")
|
||||
})
|
||||
|
||||
test("preserves native provider integration IDs", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/provider")
|
||||
return json({
|
||||
location: { directory: "/default" },
|
||||
data: [
|
||||
{
|
||||
id: "provider",
|
||||
integrationID: "integration",
|
||||
name: "Provider",
|
||||
api: { type: "native", settings: {} },
|
||||
request: { headers: {}, body: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
return json({ location: { directory: "/default" }, data: [] })
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.catalog.providers()
|
||||
|
||||
expect(result.providers.get("provider")?.integrationID).toBe("integration")
|
||||
})
|
||||
|
||||
test("preserves integration connection kinds", async () => {
|
||||
const setupResult = setup(() =>
|
||||
json({
|
||||
location: { directory: "/default" },
|
||||
data: [
|
||||
{
|
||||
id: "integration",
|
||||
name: "Integration",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "credential", label: "Saved" },
|
||||
{ type: "environment", name: "TOKEN" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
|
||||
expect(integrations).toEqual([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Integration",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ id: "credential", label: "Saved", kind: "credential" },
|
||||
{ id: "TOKEN", label: "TOKEN", kind: "environment" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
|
||||
})
|
||||
|
||||
test("switches prompt selection before admission", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.url.endsWith("/prompt")
|
||||
? json({
|
||||
data: {
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
: new Response(null, { status: 204 }),
|
||||
)
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
|
||||
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/model",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
expect(await setupResult.requests[2].json()).toEqual({
|
||||
id: "msg_1",
|
||||
prompt: {
|
||||
text: "hello",
|
||||
files: [
|
||||
{
|
||||
uri: "data:text/plain;base64,aGk=",
|
||||
mime: "application/octet-stream",
|
||||
name: "hi.txt",
|
||||
source: { start: 0, end: 2, text: "hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes selection and prompt admission per session", async () => {
|
||||
const firstPrompt = Promise.withResolvers<void>()
|
||||
const firstPromptStarted = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
|
||||
firstPromptStarted.resolve()
|
||||
await firstPrompt.promise
|
||||
}
|
||||
return path.endsWith("/prompt")
|
||||
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||
: new Response(null, { status: 204 })
|
||||
})
|
||||
|
||||
const first = setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "first",
|
||||
selection: { agent: "build" },
|
||||
})
|
||||
await firstPromptStarted.promise
|
||||
const second = setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_2",
|
||||
text: "second",
|
||||
selection: { agent: "plan" },
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
firstPrompt.resolve()
|
||||
await Promise.all([first, second])
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
"/api/session/ses_1/agent",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not switch a selection already present in session state", async () => {
|
||||
const setupResult = setup((request) =>
|
||||
request.url.endsWith("/prompt")
|
||||
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||
: json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
|
||||
)
|
||||
await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
selection: { agent: "build", model: { id: "model", providerID: "provider" } },
|
||||
})
|
||||
|
||||
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||
"/api/session/ses_1",
|
||||
"/api/session/ses_1/prompt",
|
||||
])
|
||||
})
|
||||
|
||||
test("requests file staging when selected revert files are present", async () => {
|
||||
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
|
||||
|
||||
await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
files: ["a.txt"],
|
||||
})
|
||||
|
||||
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
|
||||
})
|
||||
|
||||
test("maps ordered app prompt parts to the native prompt shape", async () => {
|
||||
const setupResult = setup(() =>
|
||||
json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
|
||||
)
|
||||
|
||||
await setupResult.backend.common.sessions.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "visible",
|
||||
parts: [
|
||||
{ id: "part_text", type: "text", text: "visible" },
|
||||
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||
],
|
||||
})
|
||||
|
||||
expect(await setupResult.requests[0].json()).toEqual({
|
||||
id: "msg_1",
|
||||
prompt: {
|
||||
text: "visiblenote",
|
||||
files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
|
||||
agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("commits a staged revert through the V2 capability", async () => {
|
||||
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||
|
||||
await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
|
||||
|
||||
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
|
||||
})
|
||||
|
||||
test("normalizes current session activity events without projection refresh", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_started",
|
||||
type: "session.next.step.started",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toMatchObject({
|
||||
location: { directory: "/repo" },
|
||||
event: {
|
||||
type: "session.activity",
|
||||
sessionID: "ses_1",
|
||||
activity: { type: "running" },
|
||||
},
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("maps completed native steps to idle activity", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_ended",
|
||||
type: "session.next.step.ended",
|
||||
data: {
|
||||
timestamp: 2,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
finish: "stop",
|
||||
cost: 1,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value?.event).toEqual({
|
||||
type: "session.activity",
|
||||
sessionID: "ses_1",
|
||||
activity: { type: "idle" },
|
||||
})
|
||||
})
|
||||
|
||||
test("projects failed steps as completed assistant errors and clears running", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "start",
|
||||
type: "session.next.step.started",
|
||||
data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
|
||||
},
|
||||
{ id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
|
||||
{ id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
|
||||
{ id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
await iterator.next()
|
||||
|
||||
expect((await iterator.next()).value?.event).toMatchObject({
|
||||
type: "session.activity",
|
||||
activity: { type: "idle" },
|
||||
item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not infer assistant parents across history pages or direct fetches", async () => {
|
||||
const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
|
||||
const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
|
||||
const setupResult = setup((request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
|
||||
if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
|
||||
return json({ data: [assistant], cursor: { next: "older" } })
|
||||
})
|
||||
|
||||
const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
|
||||
await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
|
||||
const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
|
||||
|
||||
expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
|
||||
expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
|
||||
})
|
||||
|
||||
test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
|
||||
const events = [
|
||||
{ id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
|
||||
{ id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
|
||||
{ id: "integration", type: "integration.updated", data: {} },
|
||||
{ id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
|
||||
expect((await iterator.next()).value?.event.type).toBe("unknown")
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("normalizes native session create, update, and delete lifecycle events", async () => {
|
||||
const info = {
|
||||
id: "ses_1",
|
||||
slug: "one",
|
||||
version: "2",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const events = [
|
||||
{ id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
|
||||
{ id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
|
||||
{ id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
|
||||
]
|
||||
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
|
||||
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
|
||||
expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
|
||||
})
|
||||
|
||||
test("normalizes durable session log events through the event mapper", async () => {
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_started",
|
||||
type: "session.next.step.started",
|
||||
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
const capability = setupResult.backend.capabilities.sessionExtrasV2
|
||||
if (!capability) throw new Error("Missing V2 session capability")
|
||||
|
||||
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toMatchObject({
|
||||
sequence: 7,
|
||||
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
|
||||
})
|
||||
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
|
||||
})
|
||||
|
||||
test("normalizes native todo and part removal events", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "evt_todo",
|
||||
type: "todo.updated",
|
||||
data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||
},
|
||||
{
|
||||
id: "evt_removed",
|
||||
type: "message.part.removed",
|
||||
data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
|
||||
},
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "todo.updated",
|
||||
sessionID: "ses_1",
|
||||
todos: [{ content: "Ship", status: "pending", priority: "high" }],
|
||||
})
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "timeline.part.removed",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
contentID: "part_1",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves streamed V2 timeline deltas without projection refresh", async () => {
|
||||
const setupResult = setup((request) => {
|
||||
if (new URL(request.url).pathname === "/api/event") {
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "session.next.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
textID: "text_1",
|
||||
delta: "hi",
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return json({
|
||||
data: {
|
||||
id: "msg_1",
|
||||
type: "assistant",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", id: "text_1", text: "hello" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||
|
||||
expect(result.value).toEqual({
|
||||
location: { directory: "/repo" },
|
||||
event: {
|
||||
type: "timeline.delta",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
contentID: "text_1",
|
||||
field: "text",
|
||||
delta: "hi",
|
||||
},
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("projects native fragment starts without blocking the event stream on HTTP", async () => {
|
||||
const events = [
|
||||
{
|
||||
id: "evt_step",
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
timestamp: 1,
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_text",
|
||||
type: "session.next.text.started",
|
||||
data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
|
||||
},
|
||||
]
|
||||
const setupResult = setup(
|
||||
() =>
|
||||
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
await iterator.next()
|
||||
expect((await iterator.next()).value?.event).toEqual({
|
||||
type: "timeline.content.updated",
|
||||
sessionID: "ses_1",
|
||||
itemID: "msg_1",
|
||||
content: { type: "text", id: "text_1", text: "" },
|
||||
})
|
||||
expect(setupResult.requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import type { AppClient, Capabilities, CommonClient } from "./backend"
|
||||
|
||||
type PartialApi<T> = {
|
||||
[K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
|
||||
}
|
||||
|
||||
export function createAppClient(input: {
|
||||
version?: AppClient["version"]
|
||||
common?: PartialApi<CommonClient>
|
||||
capabilities?: Capabilities
|
||||
} = {}): AppClient {
|
||||
const unsupported = async () => {
|
||||
throw new Error("Backend fixture method is not configured")
|
||||
}
|
||||
const defaults: CommonClient = {
|
||||
health: { get: unsupported },
|
||||
projects: { current: unsupported },
|
||||
catalog: { providers: unsupported, agents: unsupported },
|
||||
commands: { list: unsupported },
|
||||
references: { list: unsupported },
|
||||
sessions: {
|
||||
list: unsupported,
|
||||
create: unsupported,
|
||||
get: unsupported,
|
||||
interrupt: unsupported,
|
||||
activity: unsupported,
|
||||
history: unsupported,
|
||||
message: unsupported,
|
||||
prompt: unsupported,
|
||||
},
|
||||
files: { list: unsupported, find: unsupported, read: unsupported },
|
||||
permissions: { pending: unsupported, reply: unsupported },
|
||||
questions: { pending: unsupported, reply: unsupported, reject: unsupported },
|
||||
pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
|
||||
events: {
|
||||
async *subscribe() {},
|
||||
},
|
||||
}
|
||||
return {
|
||||
version: input.version ?? "v1",
|
||||
capabilities: input.capabilities ?? {},
|
||||
common: {
|
||||
health: { ...defaults.health, ...input.common?.health },
|
||||
projects: { ...defaults.projects, ...input.common?.projects },
|
||||
catalog: { ...defaults.catalog, ...input.common?.catalog },
|
||||
commands: { ...defaults.commands, ...input.common?.commands },
|
||||
references: { ...defaults.references, ...input.common?.references },
|
||||
sessions: { ...defaults.sessions, ...input.common?.sessions },
|
||||
files: { ...defaults.files, ...input.common?.files },
|
||||
permissions: { ...defaults.permissions, ...input.common?.permissions },
|
||||
questions: { ...defaults.questions, ...input.common?.questions },
|
||||
pty: { ...defaults.pty, ...input.common?.pty },
|
||||
events: { ...defaults.events, ...input.common?.events },
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AppMessage, AppPart, AppSession } from "./backend"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
@@ -11,7 +11,6 @@ const sessionFields = new Set([
|
||||
"session_status",
|
||||
"session_working",
|
||||
"session_diff",
|
||||
"todo",
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
@@ -24,8 +23,8 @@ export const createDirSyncContext = (
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
@@ -73,7 +72,7 @@ export const createDirSyncContext = (
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
},
|
||||
session: {
|
||||
remember(session: Session) {
|
||||
remember(session: AppSession) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
@@ -82,7 +81,7 @@ export const createDirSyncContext = (
|
||||
if (session?.directory === directory) return session
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
|
||||
serverSync.session.optimistic.add(input)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
@@ -92,7 +91,7 @@ export const createDirSyncContext = (
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
parts: AppPart[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
@@ -111,11 +110,10 @@ export const createDirSyncContext = (
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
await serverSync.session.sync(sessionID, { ...options, location: { directory } })
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
serverSync.session.evict(sessionID)
|
||||
@@ -123,17 +121,25 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
const backend = await serverSDK.backend
|
||||
const response = await backend.common.sessions.list({
|
||||
location: { directory },
|
||||
roots: true,
|
||||
limit: count,
|
||||
cursor: sessionPage.cursor,
|
||||
})
|
||||
const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
setSessionPage({ cursor: response.older, complete: !response.older })
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
more: createMemo(() => !sessionPage.complete),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
const backend = await serverSDK.backend
|
||||
const capability = backend.capabilities.sessionExtrasV1
|
||||
if (!capability) throw new Error("Server does not support session archiving")
|
||||
await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const tree = createFileTreeStore({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
list: async (dir) =>
|
||||
[...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
|
||||
(node) => ({
|
||||
name: node.name ?? getFilename(node.path),
|
||||
path: node.path,
|
||||
absolute: node.absolute ?? node.path,
|
||||
type: node.type,
|
||||
ignored: node.ignored,
|
||||
}),
|
||||
),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
.backend.then(async (client) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
const content: FileState["content"] = client.capabilities.decoratedFiles
|
||||
? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
|
||||
: await client.common.files.read({ location: { directory }, path: file }).then((value) => {
|
||||
if (value.kind !== "text") return
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: new TextDecoder().decode(value.bytes),
|
||||
mimeType: value.mimeType,
|
||||
}
|
||||
})
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
@@ -203,12 +217,25 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return promise
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs })
|
||||
.backend.then((client) =>
|
||||
client.common.files.find(
|
||||
{
|
||||
location: { directory: scope() },
|
||||
query,
|
||||
type: dirs === "true" ? undefined : "file",
|
||||
limit: options?.limit,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
(items) => items.map((item) => path.normalize(item.path)),
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.listen((e) => {
|
||||
@@ -284,7 +311,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setScrollLeft,
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
searchFiles: (query: string) => search(query, "false"),
|
||||
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
search(query, "false", options),
|
||||
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||
|
||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "../backend"
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { AppFileNode as FileNode } from "../backend"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
properties: unknown
|
||||
path?: string
|
||||
change?: string
|
||||
properties?: unknown
|
||||
}
|
||||
|
||||
type WatcherOps = {
|
||||
@@ -16,11 +18,11 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||
const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
|
||||
const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
|
||||
if (!rawPath) return
|
||||
if (!kind) return
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user