mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 05:05:16 -04:00
Compare commits
466 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4100018ec8 | |||
| f69c29b38c | |||
| 11d2eb3ec8 | |||
| cb8be9ba12 | |||
| 15046658ba | |||
| 7255ce9a57 | |||
| 2b9cf5d388 | |||
| a625d35f7b | |||
| 449c64928b | |||
| 35c88c3fc8 | |||
| b5e09024d5 | |||
| 9a51765bd2 | |||
| bb31c9b92c | |||
| 5117679148 | |||
| 0d5798c0bf | |||
| 808677a612 | |||
| fe41e9d482 | |||
| 0768c4655e | |||
| 1586a26594 | |||
| f026c1422c | |||
| b7fdb595df | |||
| 049ee1ce5e | |||
| e71fbb6d48 | |||
| c9976d69c0 | |||
| 27734409e4 | |||
| e434ce01d3 | |||
| c2f93ae2ad | |||
| 92cc8f553a | |||
| 49d997aec3 | |||
| 67aa9cee23 | |||
| 7ec3d67829 | |||
| 5401ebaede | |||
| 99668cfdce | |||
| b3a012cbdd | |||
| 51b9c726cf | |||
| b4e49d5b32 | |||
| 2b29854589 | |||
| 8ce5038bd8 | |||
| 446510a6a9 | |||
| 49028b6245 | |||
| d595c7e265 | |||
| 17cd4a8c32 | |||
| 9bbf7918de | |||
| 97f502e79a | |||
| ac5e249083 | |||
| 140e796b21 | |||
| dd02cea9e7 | |||
| a8062ea314 | |||
| f47684787a | |||
| 8168f0f0f6 | |||
| 8036440693 | |||
| cf7503687a | |||
| 6f8e1dda15 | |||
| 4dcfd9182c | |||
| d7c0db8cee | |||
| 184da0e42e | |||
| a244d82aba | |||
| 34e5809059 | |||
| 2db96c9b7e | |||
| 9976269ab1 | |||
| 5df822be11 | |||
| 233f069070 | |||
| 4a1982f5c9 | |||
| 1c7f65f105 | |||
| a84151e482 | |||
| 014696db63 | |||
| 0340a4ff77 | |||
| f4faacbe87 | |||
| 7dbfd0b934 | |||
| 08a2a623dc | |||
| 60c88336b9 | |||
| 351172dd1b | |||
| 50f8938960 | |||
| 23bc29fde5 | |||
| 2a1565951f | |||
| 4db321512f | |||
| ecaaceeecf | |||
| 8a03fc265b | |||
| d0ba538924 | |||
| 772e976d80 | |||
| c769380776 | |||
| 01131c75d8 | |||
| ea982c3832 | |||
| d09d5612f3 | |||
| 85ef35dd64 | |||
| 251177d56c | |||
| 13b9e55c72 | |||
| b94e55c7c0 | |||
| 9c6bb4ff72 | |||
| c2e019ba7d | |||
| b8374b5a7c | |||
| b4665a8bf8 | |||
| 08096b5e61 | |||
| 2e43d41af0 | |||
| 249bbd599c | |||
| 2aeeca5243 | |||
| c8f4c383cd | |||
| 25ee2435f6 | |||
| 518772c2ba | |||
| fc9021ccec | |||
| 3bebfe6b1b | |||
| 3b18c64782 | |||
| c6c599b872 | |||
| 6b41ae910c | |||
| 5c860d4142 | |||
| 4f9207daac | |||
| ae7d63272c | |||
| 5cc3a51357 | |||
| e357c90212 | |||
| fb1edc4918 | |||
| 353e0eef8a | |||
| a4c17f08b1 | |||
| 3cd9ee5a73 | |||
| 7c56319aa4 | |||
| 13fa4937b2 | |||
| 1db5c2402c | |||
| 0abbcddac2 | |||
| dd25d143c5 | |||
| d8a57ee15c | |||
| aa52d30d7f | |||
| 83d633fd13 | |||
| 824455e2f1 | |||
| 62de2b3695 | |||
| 7550aa4b89 | |||
| c33a2011c4 | |||
| fff6764ed4 | |||
| 9644a008a4 | |||
| ccb6b7c3ea | |||
| f95f877e5f | |||
| fe96d4aebd | |||
| 3a95d56144 | |||
| ecd8be7593 | |||
| a72c3880bb | |||
| ccd8676bee | |||
| 5a533e366f | |||
| 77429f5982 | |||
| 6cfe4bc34f | |||
| 95013d2936 | |||
| 14a5529793 | |||
| 0b2e8707e3 | |||
| adf178a6b9 | |||
| 78f85b1cd6 | |||
| 888dc67cc3 | |||
| fd9ee435ec | |||
| 1c25b2f298 | |||
| ea9fffce2e | |||
| 516f0266b8 | |||
| 39397deb8d | |||
| 65fd2e5c91 | |||
| c5fe32fbb1 | |||
| c48b77d6f9 | |||
| 64fd7a2027 | |||
| ae259d87f0 | |||
| 50374abab8 | |||
| 0497e8badf | |||
| 2a08f8f5f4 | |||
| 9353559088 | |||
| 6aff2ee169 | |||
| 254a481e5d | |||
| 26885e7118 | |||
| dba08013d1 | |||
| fc09646973 | |||
| c3b9c9d888 | |||
| 203885d241 | |||
| 728e86b287 | |||
| d5aa79c73a | |||
| eb6ff0c1e0 | |||
| 0df44c4e91 | |||
| 561070fbc2 | |||
| b0e41ff2c4 | |||
| 977a40af68 | |||
| dffecb6478 | |||
| 7f57d2a9ac | |||
| 377d5d2287 | |||
| 38bb38ecb2 | |||
| 3a149ba71c | |||
| 14df88eab5 | |||
| e12cb7fb6b | |||
| d4f7039932 | |||
| e0ec9be238 | |||
| 2b34df94fa | |||
| 68f225a11d | |||
| e9f5d34096 | |||
| d3459eb740 | |||
| be73f465df | |||
| f14eafe9db | |||
| b7e4f1ef74 | |||
| efd5f0a80f | |||
| 7135bc4fb5 | |||
| 7cf8d1ca6d | |||
| 78bca5c707 | |||
| 82db86e2a1 | |||
| 476e3fc382 | |||
| a12d50e15a | |||
| 1b9b260458 | |||
| a8983bd2c7 | |||
| 709af58612 | |||
| bcbbf32569 | |||
| 7a8e7c88f4 | |||
| a22676720d | |||
| f63a451b6f | |||
| 8b6a2b173b | |||
| 933dbfdb36 | |||
| b44bc0ad07 | |||
| 436cd39d14 | |||
| ed6dc879be | |||
| 96d53c6716 | |||
| 911fb7063a | |||
| 27e8e2e22b | |||
| 39c6dd1c32 | |||
| a09447bc9b | |||
| 16815473a4 | |||
| 1f47bbfce2 | |||
| 33ed95f09c | |||
| 2152624f37 | |||
| 7d84cb05ef | |||
| eba0bd0397 | |||
| 9aab24e95e | |||
| a4fed69a82 | |||
| 41a3cfcdd9 | |||
| 72845e09fc | |||
| abaab29cb3 | |||
| 30936a9bca | |||
| a9144eccf8 | |||
| 458ec7b372 | |||
| d46c02ba73 | |||
| caedf36844 | |||
| 4c6e2a92ce | |||
| bf58fae51f | |||
| eb3476660f | |||
| 7d48a22a63 | |||
| 3f0fc22b6e | |||
| 2409c7a3d5 | |||
| 379adee35c | |||
| 2ef1a5991c | |||
| 83c638eaac | |||
| cb93114424 | |||
| 04d236ceed | |||
| 5455fed78e | |||
| 6dba0cc498 | |||
| c07ac0db2f | |||
| 20b090e493 | |||
| cb54824f2f | |||
| 3adfb970bf | |||
| 5ecd19db9f | |||
| 299daa2815 | |||
| 7457139849 | |||
| 373cd08b98 | |||
| f52424e05f | |||
| 4a42caef2c | |||
| 7d2618637f | |||
| fbb95a6ee3 | |||
| 40233595b0 | |||
| 403164a6ac | |||
| 1fb2ecc721 | |||
| 27c9b130c7 | |||
| 5fecf7ae9e | |||
| a0d64b12c9 | |||
| d6b897188a | |||
| 39dfbb53d0 | |||
| a39db9c6f6 | |||
| 6ca60d9204 | |||
| b3934992d1 | |||
| 4de7e90d8d | |||
| 917eca4f55 | |||
| 3cf71808c4 | |||
| f266e829cf | |||
| 3df5556223 | |||
| fc29f99190 | |||
| beb586a383 | |||
| 5668d36477 | |||
| 3706b33ee0 | |||
| 85e8ac3b36 | |||
| 91ca75c4dc | |||
| 262ef4fdc2 | |||
| 74e5644646 | |||
| 7de5aa08ff | |||
| 9495e3e903 | |||
| 1e73b76ea6 | |||
| 6697cf3fd8 | |||
| 87aa62d1e2 | |||
| 3136b1ba97 | |||
| 246dea3a58 | |||
| 4b1c7f28a0 | |||
| 9410eda4e3 | |||
| f014686b07 | |||
| 8b68dc0d7f | |||
| 2f4d36d64e | |||
| 6f2ff35656 | |||
| cd00422298 | |||
| 34ee94232f | |||
| 4d91f0cf28 | |||
| bf18cc971f | |||
| 5e1a1ed2e2 | |||
| bd7f7ad98b | |||
| af72cec799 | |||
| 55552c521f | |||
| 2b611a5b14 | |||
| 45a437cbb2 | |||
| 439ec9b76f | |||
| ae3fefb42a | |||
| f1407e41c4 | |||
| c88ca972cf | |||
| 928c37bc02 | |||
| afff74eb2c | |||
| 3a669d528b | |||
| 6789214b31 | |||
| 6636683323 | |||
| 3af9f64265 | |||
| 5a23bdcb69 | |||
| e78726854a | |||
| 472d0f376e | |||
| c8fde60ad4 | |||
| a4b6047e64 | |||
| 6387f955cf | |||
| 5691f36e98 | |||
| 74e7a378bb | |||
| fced9c5a2f | |||
| 20445ca031 | |||
| 6fc9405618 | |||
| c86066efef | |||
| fa9ba2938e | |||
| 63e24880e5 | |||
| eaf42c4ab0 | |||
| 8e10ab0aa7 | |||
| b329fcceb2 | |||
| bd83177c48 | |||
| 06cb0de0b1 | |||
| 3ca89ac796 | |||
| 3aa2860058 | |||
| ad63cf1590 | |||
| aa56750f8b | |||
| 4aaed42640 | |||
| f69f677a32 | |||
| 8289883de8 | |||
| 003c22b4a3 | |||
| 1982d9832b | |||
| 90f0576222 | |||
| e34822db6b | |||
| b19ae6547f | |||
| 6b074ed718 | |||
| e687eb936b | |||
| 797cb530e5 | |||
| 0a5bed2bc2 | |||
| ba41dadc91 | |||
| 451876b0bd | |||
| 91561bbdfb | |||
| 018ce34b39 | |||
| ce82b21073 | |||
| 1f37c26574 | |||
| cb3e28d207 | |||
| 8f1e13f299 | |||
| 2fe68b5e91 | |||
| 2ace237938 | |||
| 5b62211167 | |||
| 60b6229c4e | |||
| 4caec80e67 | |||
| e9d4ca7e0f | |||
| d71454c701 | |||
| 884c256033 | |||
| 78235385dd | |||
| fd213e6df6 | |||
| 3726052307 | |||
| 7d33a6f7c9 | |||
| 7a035d7fc0 | |||
| 9151af7045 | |||
| 15bcbb1d7a | |||
| 4d3294727c | |||
| aae0e89519 | |||
| 93a7b4ab76 | |||
| 0ebe74b625 | |||
| b9dae8593c | |||
| d8f9388610 | |||
| be14739cce | |||
| 762588c251 | |||
| d6e54e9042 | |||
| d4fd528152 | |||
| de185559dd | |||
| bc5ce5eab1 | |||
| b10d617c80 | |||
| 18466b8020 | |||
| 71ec022b47 | |||
| f7eeb08942 | |||
| 9205dfe724 | |||
| a3776429aa | |||
| c0e43c0c65 | |||
| 08c5a2a5e8 | |||
| 7077c70d60 | |||
| 1fd8bf526d | |||
| 6d9539f469 | |||
| e5101d9651 | |||
| b0151e1d02 | |||
| c3637753bc | |||
| 48fc9e3cc3 | |||
| 82a482b36d | |||
| 7fac84319d | |||
| 0a5e617da8 | |||
| b5f92c9f48 | |||
| 2070fd9bc2 | |||
| 48dc05eee7 | |||
| 84bb706537 | |||
| be8cfa7e08 | |||
| 56a789d926 | |||
| 5409151ad4 | |||
| f90d154465 | |||
| 078385d386 | |||
| beaaa174ea | |||
| fb59606bb4 | |||
| 846d548154 | |||
| 84336e4f91 | |||
| 01a5c69244 | |||
| d78f91afeb | |||
| 33762292f7 | |||
| 5d6aa3b41a | |||
| 683aca5dbe | |||
| 92025e9a47 | |||
| 411e053572 | |||
| b862d178bf | |||
| bda0ddc207 | |||
| 58ba99e505 | |||
| 6ee817d041 | |||
| dfeb1b5051 | |||
| 61a7f6db35 | |||
| 6446b8ae3b | |||
| ae53163cad | |||
| 41202819a4 | |||
| a31698f99b | |||
| 6248542c49 | |||
| d25c91e5eb | |||
| 5d63020dcd | |||
| 062f54590e | |||
| a76c6918d2 | |||
| 2b91a6f210 | |||
| 10579cceb2 | |||
| 25702e01ce | |||
| ecc5c44d9a | |||
| f5a0b920a2 | |||
| 2caa016fe1 | |||
| 6861fedd09 | |||
| 3d072112ce | |||
| bdfea046db | |||
| a2d08fb63b | |||
| 5a55135d89 | |||
| 8870d36e0f | |||
| eb923c27ca | |||
| 9903abc704 | |||
| 225a1fbf35 | |||
| 93159bccbf | |||
| fab8ec4f54 | |||
| a3035c53ea | |||
| 36c416e143 | |||
| e1e0304a96 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -32,9 +31,6 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -126,7 +122,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -225,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -451,7 +447,6 @@ 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,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -75,9 +74,13 @@ 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, v2]
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -13,7 +13,6 @@ tmp
|
||||
dist
|
||||
ts-dist
|
||||
.turbo
|
||||
.typecheck-profiles
|
||||
**/.serena
|
||||
.serena/
|
||||
**/.omo
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
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,7 +1,6 @@
|
||||
- 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.
|
||||
|
||||
@@ -19,15 +18,12 @@ 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()`
|
||||
@@ -154,15 +150,12 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- 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 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 `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 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.
|
||||
- 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.
|
||||
- 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 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 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 EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**System Context**:
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||
_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.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**System Context Registry**:
|
||||
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||
|
||||
**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
|
||||
|
||||
**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.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
_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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_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**.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `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.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.
|
||||
- `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.
|
||||
- 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 **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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## 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**."
|
||||
|
||||
## 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.
|
||||
Binary file not shown.
@@ -495,6 +495,7 @@ 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"],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
|
||||
"aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
|
||||
"aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
|
||||
"x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
|
||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -2,24 +2,18 @@
|
||||
"$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/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/opencode --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",
|
||||
"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",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
@@ -102,9 +96,6 @@
|
||||
},
|
||||
"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:",
|
||||
|
||||
@@ -20,7 +20,7 @@ const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "patch",
|
||||
tool: "apply_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, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "apply_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,
|
||||
"patch",
|
||||
"apply_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,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
|
||||
@@ -33,9 +33,11 @@ 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"
|
||||
@@ -47,6 +49,7 @@ 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",
|
||||
@@ -102,6 +105,7 @@ 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: "patch",
|
||||
tool: "apply_patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
|
||||
@@ -131,7 +131,7 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "patch"
|
||||
(tool === "apply_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, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
@@ -269,6 +269,7 @@ 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|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|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))
|
||||
|
||||
@@ -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|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|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, {})
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"apply_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,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
|
||||
@@ -35,6 +35,7 @@ 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",
|
||||
@@ -64,6 +65,7 @@ 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 }) => {
|
||||
@@ -247,7 +249,7 @@ function editPart(id: string) {
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
|
||||
@@ -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", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const ordinary = ["bash", "edit", "write", "apply_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,11 +17,13 @@ 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()
|
||||
}
|
||||
@@ -88,7 +90,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 === "patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "apply_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" }
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|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 === "patch"
|
||||
tool === "apply_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, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "apply_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,6 +229,7 @@ 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,6 +17,7 @@ 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>
|
||||
@@ -55,7 +56,12 @@ 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: true })
|
||||
if (path === "/api/session")
|
||||
return json(route, {
|
||||
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
||||
cursor: {},
|
||||
})
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
@@ -105,6 +111,8 @@ 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$/)
|
||||
@@ -129,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||
? { archived: session.time.archived }
|
||||
: {}),
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
},
|
||||
...(typeof session.path === "string" ? { subpath: session.path } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -177,7 +177,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="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
@@ -65,7 +67,7 @@ export function TabsInfoPopup() {
|
||||
<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."
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,7 +109,7 @@ export function TabsInfoPopup() {
|
||||
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.
|
||||
Organize your work and active sessions with tabs
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -127,16 +129,32 @@ export function TabsInfoPopup() {
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto 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.
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<p>OpenCode Desktop is now built around tabs.</p>
|
||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>
|
||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||
you're starting something new, and close it when you're done.
|
||||
</p>
|
||||
<p>
|
||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||
memorable if you plan to keep them around.
|
||||
</p>
|
||||
<p>
|
||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||
</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout , you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @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() {
|
||||
@@ -102,6 +104,94 @@ 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",
|
||||
@@ -116,3 +206,12 @@ 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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ 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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[data-component="server-status-icon"] {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
|
||||
[data-slot="server-status-left"],
|
||||
[data-slot="server-status-right"] {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
[data-slot="server-status-dot"] {
|
||||
fill: var(--server-status-color);
|
||||
}
|
||||
|
||||
&[data-state="reconnecting"] {
|
||||
--server-status-color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
&[data-state="disconnected"] {
|
||||
--server-status-color: var(--v2-state-fg-danger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { ServerStatusIcon, type ServerStatusIconState } from "./server-status-icon"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Server Status Icon",
|
||||
id: "desktop-v2-server-status-icon",
|
||||
component: ServerStatusIcon,
|
||||
tags: ["autodocs"],
|
||||
}
|
||||
|
||||
function Preview(props: { state: ServerStatusIconState; label: string }) {
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
aria-label={props.label}
|
||||
icon={<ServerStatusIcon state={props.state} />}
|
||||
/>
|
||||
<span class="text-[13px] text-v2-text-text-muted">{props.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Reconnecting = {
|
||||
render: () => <Preview state="reconnecting" label="Retrying automatically..." />,
|
||||
}
|
||||
|
||||
export const Disconnected = {
|
||||
render: () => <Preview state="disconnected" label="Server disconnected" />,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveServerStatus } from "./server-status-icon"
|
||||
|
||||
describe("resolveServerStatus", () => {
|
||||
test("prioritizes a disconnected server over stream reconnection", () => {
|
||||
expect(resolveServerStatus(false, "reconnecting")).toBe("disconnected")
|
||||
})
|
||||
|
||||
test("shows reconnection while the server remains reachable", () => {
|
||||
expect(resolveServerStatus(true, "reconnecting")).toBe("reconnecting")
|
||||
})
|
||||
|
||||
test("stays hidden for healthy and initial connections", () => {
|
||||
expect(resolveServerStatus(true, "connected")).toBeUndefined()
|
||||
expect(resolveServerStatus(undefined, "connecting")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import "./server-status-icon.css"
|
||||
|
||||
export type ServerStatusIconState = "reconnecting" | "disconnected"
|
||||
export type ServerConnectionState = "connecting" | "connected" | "reconnecting"
|
||||
|
||||
export function resolveServerStatus(healthy: boolean | undefined, connection: ServerConnectionState) {
|
||||
if (healthy === false) return "disconnected" as const
|
||||
if (connection === "reconnecting") return "reconnecting" as const
|
||||
}
|
||||
|
||||
export function ServerStatusIcon(props: { state: ServerStatusIconState }) {
|
||||
return (
|
||||
<svg
|
||||
data-component="server-status-icon"
|
||||
data-state={props.state}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M13.3141 6.00001V14.4572H2.68555V1.54285H9.5M13.3141 14.4572V9.54285H2.68555V14.4572"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path data-slot="server-status-left" d="M8.5 11.75H8V12.25H8.5Z" />
|
||||
<path data-slot="server-status-right" d="M11 11.75H10.5V12.25H11Z" />
|
||||
<path
|
||||
data-slot="server-status-dot"
|
||||
d="M13 5C14.3807 5 15.5 3.88071 15.5 2.5C15.5 1.11929 14.3807 0 13 0C11.6193 0 10.5 1.11929 10.5 2.5C10.5 3.88071 11.6193 5 13 5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @refresh reload
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -16,7 +17,9 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
@@ -33,6 +36,8 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
import { useTitlebarRightMount } from "../titlebar"
|
||||
import { useSettingsDialog } from "../settings-dialog"
|
||||
import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -144,6 +149,8 @@ export function SessionHeader() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const global = useGlobal()
|
||||
const serverSDK = useServerSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
@@ -165,6 +172,7 @@ export function SessionHeader() {
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const openServerSettings = useSettingsDialog("servers")
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -234,9 +242,25 @@ export function SessionHeader() {
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||
)
|
||||
const serverStatus = createMemo<ServerStatusIconState | undefined>(() => {
|
||||
const current = serverSDK()
|
||||
return resolveServerStatus(
|
||||
global.servers.health[ServerConnection.key(current.server)]?.healthy,
|
||||
current.connection.status,
|
||||
)
|
||||
})
|
||||
const serverStatusLabel = createMemo(() => {
|
||||
if (serverStatus() === "disconnected") {
|
||||
return language.t("app.server.status.disconnected", { server: serverName(serverSDK().server) })
|
||||
}
|
||||
return language.t("app.server.status.reconnecting", { server: serverName(serverSDK().server) })
|
||||
})
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
serverStatus: isDesktop() ? serverStatus() : undefined,
|
||||
serverStatusLabel: serverStatusLabel(),
|
||||
onServerStatusClick: openServerSettings,
|
||||
toolsVisible: isDesktop() && status(),
|
||||
statusLabel: language.t("status.popover.tools.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
@@ -517,7 +541,10 @@ export function SessionHeader() {
|
||||
}
|
||||
|
||||
type SessionHeaderV2ActionsState = {
|
||||
statusVisible: boolean
|
||||
serverStatus?: ServerStatusIconState
|
||||
serverStatusLabel: string
|
||||
onServerStatusClick: () => void
|
||||
toolsVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string[]
|
||||
@@ -530,11 +557,26 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<div class="flex items-center gap-[6px]">
|
||||
<Show when={props.state.serverStatus}>
|
||||
{(status) => (
|
||||
<TooltipV2 placement="bottom" value={props.state.serverStatusLabel}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
onClick={props.state.onServerStatusClick}
|
||||
aria-label={props.state.serverStatusLabel}
|
||||
icon={<ServerStatusIcon state={status()} />}
|
||||
/>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.state.toolsVisible}>
|
||||
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
|
||||
@@ -224,7 +224,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="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="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>
|
||||
|
||||
@@ -377,8 +377,8 @@ export const SettingsGeneralV2: Component<{
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
title={language.t("settings.general.row.showTools.title")}
|
||||
description={language.t("settings.general.row.showTools.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
|
||||
@@ -221,7 +221,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="session.synthetic"
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ToolsMenu } from "./tools-menu"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -280,7 +281,14 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const configuredMcpNames = createMemo(() =>
|
||||
Object.keys(sync().data.config.mcp ?? {}).sort((a, b) => a.localeCompare(b)),
|
||||
)
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const mcpError = (name: string) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
if (status?.status === "failed" || status?.status === "needs_client_registration") return status.error
|
||||
}
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
@@ -289,6 +297,42 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
if (settings.general.newLayoutDesigns()) {
|
||||
return (
|
||||
<ToolsMenu
|
||||
labels={{
|
||||
menu: language.t("status.popover.tools.trigger"),
|
||||
mcp: language.t("status.popover.tab.mcp"),
|
||||
lsp: language.t("status.popover.tab.lsp"),
|
||||
plugins: language.t("status.popover.tab.plugins"),
|
||||
mcpDescription: language.t("status.popover.tools.mcp.description"),
|
||||
lspDescription: language.t("status.popover.tools.lsp.description"),
|
||||
pluginsDescription: language.t("status.popover.tools.plugins.description"),
|
||||
disabled: language.t("mcp.status.disabled"),
|
||||
failed: language.t("mcp.status.failed"),
|
||||
reauthenticate: language.t("status.popover.tools.reauthenticate"),
|
||||
}}
|
||||
empty={{
|
||||
mcp: language.t("status.popover.tools.mcp.empty"),
|
||||
lsp: language.t("status.popover.tools.lsp.empty"),
|
||||
plugins: language.t("status.popover.tools.plugins.empty"),
|
||||
}}
|
||||
mcp={configuredMcpNames().map((name) => ({
|
||||
name,
|
||||
status: mcpStatus(name),
|
||||
error: mcpError(name),
|
||||
loading: !mcpStatus(name),
|
||||
pending: toggleMcp.isPending && toggleMcp.variables === name,
|
||||
onToggle: () => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
},
|
||||
}))}
|
||||
lsp={lspItems().map((item) => ({ name: item.name || item.id, status: item.status }))}
|
||||
plugins={plugins()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @refresh reload
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ToolsMenuIcon } from "./tools-menu"
|
||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
@@ -87,7 +88,10 @@ function DirectoryStatusPopover() {
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
mcp: Object.keys(sync().data.config.mcp ?? {}).flatMap((name) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
return status ? [status.status] : []
|
||||
}),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
}),
|
||||
)
|
||||
@@ -96,7 +100,7 @@ function DirectoryStatusPopover() {
|
||||
ready: ready(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: issue(),
|
||||
label: language.t("status.popover.trigger"),
|
||||
label: language.t("status.popover.tools.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
@@ -174,14 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
trigger={<ToolsMenuIcon warning={props.state.issue} />}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { For, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ToolsMenu, ToolsMenuIcon, type ToolsMenuProps, type ToolsMenuStatus, type ToolsMenuTab } from "./tools-menu"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Tools Menu",
|
||||
id: "desktop-v2-tools-menu",
|
||||
component: ToolsMenu,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `The desktop V2 Tools menu shown in the titlebar. Stories cover every MCP status, both LSP states, populated plugin rows, and empty states for all tabs.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const labels = {
|
||||
menu: "Tools",
|
||||
mcp: "MCP",
|
||||
lsp: "LSP",
|
||||
plugins: "Plugins",
|
||||
mcpDescription: "Add MCP servers in opencode.json",
|
||||
lspDescription: "LSPs are auto-detected as you open files",
|
||||
pluginsDescription: "Add plugins in opencode.json",
|
||||
disabled: "Disabled",
|
||||
failed: "Failed",
|
||||
reauthenticate: "Reauthenticate",
|
||||
}
|
||||
|
||||
const empty = {
|
||||
mcp: "No MCPs configured yet",
|
||||
lsp: "No active language servers yet",
|
||||
plugins: "No plugins configured yet",
|
||||
}
|
||||
|
||||
const mcp = [
|
||||
{ name: "ui-docs", status: "needs_auth" },
|
||||
{ name: "browser-tools", status: "needs_client_registration" },
|
||||
{ name: "brave-search", status: "disabled" },
|
||||
{ name: "fastmail-mcp", status: "connected" },
|
||||
{ name: "puppeteer", status: "failed" },
|
||||
{ name: "figma", status: "connected" },
|
||||
{ name: "playwright", status: "failed" },
|
||||
] satisfies Array<{ name: string; status: ToolsMenuStatus }>
|
||||
|
||||
const lsp = [
|
||||
{ name: "typescript", status: "connected" },
|
||||
{ name: "eslint", status: "connected" },
|
||||
{ name: "rust-analyzer", status: "error" },
|
||||
] satisfies ToolsMenuProps["lsp"]
|
||||
|
||||
const plugins = ["opencode-devcontainers", "opencode-daytona", "~/.config/opencode/plugins/ping.ts"]
|
||||
|
||||
const populated = {
|
||||
labels,
|
||||
empty,
|
||||
mcp,
|
||||
lsp,
|
||||
plugins,
|
||||
} satisfies Omit<ToolsMenuProps, "defaultTab">
|
||||
|
||||
function InteractiveMenu() {
|
||||
const [state, setState] = createStore({ mcp: mcp.map((item) => ({ ...item, pending: false })) })
|
||||
return (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
mcp={state.mcp.map((item, index) => ({
|
||||
...item,
|
||||
onToggle: () => {
|
||||
if (item.status === "needs_auth" || item.status === "needs_client_registration") return
|
||||
setState("mcp", index, "status", item.status === "connected" ? "disabled" : "connected")
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuPreview(props: { warning?: boolean; children: JSXElement }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex h-7 items-center gap-2 text-[11px] font-[530] uppercase tracking-[0.05px] text-v2-text-text-faint">
|
||||
<ToolsMenuIcon warning={props.warning} />
|
||||
{props.warning ? "warning" : "normal"}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Gallery(props: { empty?: boolean }) {
|
||||
const tabs: ToolsMenuTab[] = ["mcp", "lsp", "plugins"]
|
||||
return (
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<MenuPreview warning={!props.empty && tab !== "plugins"}>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
defaultTab={tab}
|
||||
mcp={props.empty ? [] : populated.mcp}
|
||||
lsp={props.empty ? [] : populated.lsp}
|
||||
plugins={props.empty ? [] : populated.plugins}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive = {
|
||||
render: () => <InteractiveMenu />,
|
||||
}
|
||||
|
||||
export const AllTabs = {
|
||||
name: "All populated tabs",
|
||||
render: () => <Gallery />,
|
||||
}
|
||||
|
||||
export const EmptyStates = {
|
||||
name: "All empty tabs",
|
||||
render: () => <Gallery empty />,
|
||||
}
|
||||
|
||||
export const McpPending = {
|
||||
name: "MCP pending toggle",
|
||||
render: () => (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu {...populated} mcp={populated.mcp.map((item) => ({ ...item, pending: item.name === "figma" }))} />
|
||||
</MenuPreview>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { For, Index, Show, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type ToolsMenuTab = "mcp" | "lsp" | "plugins"
|
||||
export type ToolsMenuStatus = "connected" | "failed" | "error" | "needs_auth" | "needs_client_registration" | "disabled"
|
||||
|
||||
export type ToolsMenuProps = {
|
||||
defaultTab?: ToolsMenuTab
|
||||
labels: {
|
||||
menu: string
|
||||
mcp: string
|
||||
lsp: string
|
||||
plugins: string
|
||||
mcpDescription: string
|
||||
lspDescription: string
|
||||
pluginsDescription: string
|
||||
disabled: string
|
||||
failed: string
|
||||
reauthenticate: string
|
||||
}
|
||||
empty: {
|
||||
mcp: JSXElement
|
||||
lsp: JSXElement
|
||||
plugins: JSXElement
|
||||
}
|
||||
mcp: Array<{
|
||||
name: string
|
||||
status?: ToolsMenuStatus
|
||||
error?: string
|
||||
loading?: boolean
|
||||
pending?: boolean
|
||||
onToggle?: () => void
|
||||
}>
|
||||
lsp: Array<{
|
||||
name: string
|
||||
status: "connected" | "error"
|
||||
}>
|
||||
plugins: string[]
|
||||
}
|
||||
|
||||
export function ToolsMenuIcon(props: { warning?: boolean }) {
|
||||
return (
|
||||
<div class="relative size-4 text-v2-icon-icon-muted">
|
||||
<Icon name={props.warning ? "tools-warning" : "tools"} />
|
||||
<Show when={props.warning}>
|
||||
<div class="absolute right-0 top-0 size-[5px] rounded-full bg-icon-warning-base" />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsMenu(props: ToolsMenuProps) {
|
||||
const [state, setState] = createStore({ tab: props.defaultTab ?? ("mcp" as ToolsMenuTab) })
|
||||
|
||||
return (
|
||||
<div class="w-[360px] max-w-[calc(100vw-40px)] rounded-xl bg-v2-background-bg-base p-4 [font-family:var(--v2-font-family-sans)] shadow-[var(--v2-elevation-floating)]">
|
||||
<SegmentedControlV2
|
||||
value={state.tab}
|
||||
onChange={(value) => value && setState("tab", value as ToolsMenuTab)}
|
||||
class="segmented-control-v2--full-width"
|
||||
aria-label={props.labels.menu}
|
||||
>
|
||||
<SegmentedControlItemV2 value="mcp">{props.labels.mcp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="lsp">{props.labels.lsp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="plugins">{props.labels.plugins}</SegmentedControlItemV2>
|
||||
</SegmentedControlV2>
|
||||
|
||||
<Show when={state.tab === "mcp"}>
|
||||
<Show
|
||||
when={props.mcp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.mcp} description={props.labels.mcpDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.mcpDescription}>
|
||||
<Index each={props.mcp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item().status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item().name}
|
||||
</span>
|
||||
<Show
|
||||
when={item().status === "needs_auth"}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={item().status === "needs_client_registration"}>
|
||||
<span
|
||||
class="max-w-40 truncate text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint"
|
||||
title={item().error}
|
||||
>
|
||||
{item().error ?? props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item().status !== "needs_client_registration"}>
|
||||
<Show when={item().status === "disabled" || item().status === "failed"}>
|
||||
<span class="text-[11px] font-[440] capitalize leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{item().status === "disabled" ? props.labels.disabled : props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Switch
|
||||
checked={item().status === "connected"}
|
||||
disabled={item().loading}
|
||||
hideLabel
|
||||
onChange={item().onToggle}
|
||||
readOnly={item().pending}
|
||||
>
|
||||
{item().name}
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="small" variant="outline" disabled={item().pending} onClick={item().onToggle}>
|
||||
{props.labels.reauthenticate}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "lsp"}>
|
||||
<Show
|
||||
when={props.lsp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.lsp} description={props.labels.lspDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.lspDescription}>
|
||||
<For each={props.lsp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item.status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "plugins"}>
|
||||
<Show
|
||||
when={props.plugins.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.plugins} description={props.labels.pluginsDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.pluginsDescription}>
|
||||
<For each={props.plugins}>
|
||||
{(plugin) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status="connected" />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{plugin}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsList(props: { description: string; children: JSXElement }) {
|
||||
return (
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div class="flex h-8 items-center px-2 text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{props.description}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsEmpty(props: { title: JSXElement; description: string }) {
|
||||
return (
|
||||
<div class="mt-4 flex flex-col gap-2 text-center text-[13px] leading-4 tracking-[-0.04px]">
|
||||
<div class="h-4 font-[530] text-v2-text-text-muted">{props.title}</div>
|
||||
<div class="h-4 font-[440] text-v2-text-text-faint">{props.description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot(props: { status?: ToolsMenuStatus }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 shrink-0 rounded-full": true,
|
||||
"bg-v2-state-fg-success": props.status === "connected",
|
||||
"bg-v2-state-fg-danger": props.status === "failed" || props.status === "error",
|
||||
"bg-v2-state-fg-warning": props.status === "needs_auth" || props.status === "needs_client_registration",
|
||||
"bg-v2-border-border-base": props.status === "disabled",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const sessionFields = new Set([
|
||||
"session_status",
|
||||
"session_working",
|
||||
"session_diff",
|
||||
"todo",
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
@@ -114,6 +115,7 @@ export const createDirSyncContext = (
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
serverSync.session.evict(sessionID)
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "filesystem.changed") return
|
||||
if (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
|
||||
|
||||
@@ -30,6 +30,7 @@ function directoryState() {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
|
||||
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps non-bootstrapping children passive until a real directory access", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
const offset = querySingles.length
|
||||
const bootstraps: string[] = []
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap(directory) {
|
||||
bootstraps.push(directory)
|
||||
},
|
||||
onMcp() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [store] = manager.child("/project", { bootstrap: false })
|
||||
const queries = querySingles.slice(offset)
|
||||
|
||||
expect(queries).toHaveLength(6)
|
||||
expect(queries[0]?.().enabled).toBe(false)
|
||||
expect(queries[3]?.().enabled).toBe(false)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
expect(queries[5]?.().enabled).toBe(false)
|
||||
expect(store.path.directory).toBe("/project")
|
||||
expect(store.provider_ready).toBe(false)
|
||||
expect(store.lsp_ready).toBe(false)
|
||||
expect(bootstraps).toEqual([])
|
||||
|
||||
manager.child("/project")
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[3]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
expect(queries[5]?.().enabled).toBe(true)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
|
||||
manager.child("/project", { bootstrap: false })
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
|
||||
const disposers = new Map<string, () => void>()
|
||||
const mcpDirectories = new Set<string>()
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const activeDirectories = new Set<string>()
|
||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
const markKey = (key: DirectoryKey) => {
|
||||
if (!key) return
|
||||
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
|
||||
lifecycle.delete(key)
|
||||
mcpDirectories.delete(key)
|
||||
mcpToggles.delete(key)
|
||||
activeDirectories.delete(key)
|
||||
activationToggles.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
}))
|
||||
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return !providerQuery.isLoading
|
||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
@@ -223,6 +234,7 @@ export function createChildStoreManager(input: {
|
||||
return (type ?? "idle") !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
@@ -235,7 +247,7 @@ export function createChildStoreManager(input: {
|
||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||
},
|
||||
get lsp_ready() {
|
||||
return !lspQuery.isLoading
|
||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||
},
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
@@ -249,6 +261,7 @@ export function createChildStoreManager(input: {
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
@@ -289,6 +302,7 @@ export function createChildStoreManager(input: {
|
||||
pinForOwner(key)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap) activate(key)
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
@@ -300,6 +314,7 @@ export function createChildStoreManager(input: {
|
||||
const childStore = ensureChild(directory)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap) activate(key)
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
@@ -313,6 +328,16 @@ export function createChildStoreManager(input: {
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
// Passive Home/project metadata reads must not initialize the directory.
|
||||
// A real directory access enables these queries once for the store lifetime.
|
||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||
// updated-time v2.session.list, remove any Home-only passive child creation.
|
||||
function activate(key: DirectoryKey) {
|
||||
if (activeDirectories.has(key)) return
|
||||
activeDirectories.add(key)
|
||||
activationToggles.get(key)?.(true)
|
||||
}
|
||||
|
||||
function disableMcp(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!mcpDirectories.delete(key)) return
|
||||
@@ -359,6 +384,7 @@ export function createChildStoreManager(input: {
|
||||
unpin,
|
||||
pinned,
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
disposeDirectory,
|
||||
runEviction,
|
||||
|
||||
@@ -72,6 +72,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
@@ -215,6 +216,7 @@ describe("applyDirectoryEvent", () => {
|
||||
message: { ses_1: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
@@ -235,11 +237,28 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores an archived session absent from a passive directory store", () => {
|
||||
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session).toEqual([])
|
||||
expect(store.sessionTotal).toBe(0)
|
||||
})
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||
@@ -259,6 +278,7 @@ describe("applyDirectoryEvent", () => {
|
||||
message: { [item.info.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
question: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
@@ -279,6 +299,7 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.message[item.info.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[item.info.id]).toBeUndefined()
|
||||
expect(store.permission[item.info.id]).toBeUndefined()
|
||||
expect(store.question[item.info.id]).toBeUndefined()
|
||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||
@@ -289,6 +310,7 @@ describe("applyDirectoryEvent", () => {
|
||||
const dropped = rootSession({ id: "ses_b" })
|
||||
const kept = rootSession({ id: "ses_a" })
|
||||
const message = userMessage("msg_1", dropped.id)
|
||||
const todos: string[] = []
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
@@ -296,6 +318,7 @@ describe("applyDirectoryEvent", () => {
|
||||
message: { [dropped.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
@@ -309,15 +332,21 @@ describe("applyDirectoryEvent", () => {
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
setSessionTodo(sessionID, value) {
|
||||
if (value !== undefined) return
|
||||
todos.push(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||
expect(store.message[dropped.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
|
||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
@@ -18,6 +19,7 @@ import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const SESSION_CONTENT_EVENTS = new Set([
|
||||
"session.diff",
|
||||
"todo.updated",
|
||||
"session.status",
|
||||
"message.updated",
|
||||
"message.removed",
|
||||
@@ -60,8 +62,13 @@ export function applyGlobalEvent(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
|
||||
function cleanupSessionCaches(
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
@@ -69,11 +76,17 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
@@ -82,6 +95,9 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
|
||||
.filter((sessionID): sessionID is string => !!sessionID),
|
||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||
if (stale.length === 0) return
|
||||
for (const sessionID of stale) {
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
@@ -98,6 +114,7 @@ export function applyDirectoryEvent(input: {
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
sessionContent?: boolean
|
||||
permission?: State["permission"]
|
||||
@@ -121,7 +138,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
@@ -129,16 +146,15 @@ export function applyDirectoryEvent(input: {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (!result.found) break
|
||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id)
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -151,7 +167,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
@@ -165,16 +181,22 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
HOME_V2_SESSION_PAGE_LIMIT,
|
||||
loadHomeSessionIndex,
|
||||
homeSessionIndexSessions,
|
||||
homeSessionIndexRefresh,
|
||||
parseHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "./home-session-index"
|
||||
|
||||
const session = (input: {
|
||||
id: string
|
||||
directory?: string
|
||||
parentID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}) => ({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||
title: input.id,
|
||||
location: { directory: input.directory ?? "/project" },
|
||||
})
|
||||
|
||||
describe("Home V2 session index", () => {
|
||||
test("loads the Home index with one global V2 request", async () => {
|
||||
const calls: unknown[] = []
|
||||
const result = await loadHomeSessionIndex(async (input) => {
|
||||
calls.push(input)
|
||||
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||
})
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||
})
|
||||
|
||||
test("loads subsequent pages until the session index is complete", async () => {
|
||||
const calls: unknown[] = []
|
||||
const controller = new AbortController()
|
||||
const result = await loadHomeSessionIndex(
|
||||
async (input, options) => {
|
||||
calls.push({ input, signal: options.signal })
|
||||
if (!("cursor" in input)) {
|
||||
return {
|
||||
data: {
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||
session({ id: `page-1-${index}` }),
|
||||
),
|
||||
cursor: { next: "next-page" },
|
||||
},
|
||||
}
|
||||
}
|
||||
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||
},
|
||||
0,
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||
expect(calls).toEqual([
|
||||
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||
{
|
||||
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||
signal: controller.signal,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("maps visible roots to Home session summaries", () => {
|
||||
const result = parseHomeSessionIndex([
|
||||
session({ id: "root", updated: 30 }),
|
||||
session({ id: "child", parentID: "root", updated: 40 }),
|
||||
session({ id: "archived", archived: 50, updated: 50 }),
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
slug: "root",
|
||||
version: "",
|
||||
directory: "/project",
|
||||
projectID: "project",
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the per-directory Home retention limit", () => {
|
||||
const now = 10 * 60 * 60 * 1000
|
||||
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||
directory: index % 2 === 0 ? "/one" : "/two",
|
||||
}))
|
||||
|
||||
const retained = retainHomeSessions(sessions, 10, now)
|
||||
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||
})
|
||||
|
||||
test("replays session events over the loaded index", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||
|
||||
const afterCreate = applyHomeSessionEvent(initial, {
|
||||
type: "session.created",
|
||||
properties: { sessionID: created.id, info: created },
|
||||
})
|
||||
expect(
|
||||
applyHomeSessionEvent(afterCreate, {
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||
}),
|
||||
).toEqual([created])
|
||||
})
|
||||
|
||||
test("applies only events newer than the index baseline", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const stale = { ...initial[0], title: "stale" }
|
||||
const current = { ...initial[0], title: "current" }
|
||||
const first = appendHomeSessionEvent(undefined, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: stale.id, info: stale },
|
||||
})
|
||||
const events = appendHomeSessionEvent(first, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: current.id, info: current },
|
||||
})
|
||||
|
||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||
})
|
||||
|
||||
test("refetches after reconnect, disposal, and session moves", () => {
|
||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export type HomeSessionEvent = {
|
||||
type: "session.created" | "session.updated" | "session.deleted"
|
||||
properties: { sessionID: string; info: Session }
|
||||
}
|
||||
export type HomeSessionEvents = {
|
||||
sequence: number
|
||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||
}
|
||||
export type HomeSessionIndex = {
|
||||
sessions: Session[]
|
||||
eventSequence: number
|
||||
}
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<HomeSessionPage>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionV2Info[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
for (;;) {
|
||||
const response = await list(
|
||||
{
|
||||
limit: HOME_V2_SESSION_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const page = response.data!
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
cursor = page.cursor.next
|
||||
}
|
||||
}
|
||||
|
||||
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||
const sequence = (current?.sequence ?? 0) + 1
|
||||
return {
|
||||
sequence,
|
||||
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||
}
|
||||
}
|
||||
|
||||
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||
return {
|
||||
sequence: current?.sequence ?? sequence,
|
||||
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||
}
|
||||
}
|
||||
|
||||
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
if (!index) return []
|
||||
return (events?.entries ?? [])
|
||||
.filter((entry) => entry.sequence > index.eventSequence)
|
||||
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||
}
|
||||
|
||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||
return {
|
||||
connected,
|
||||
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||
}
|
||||
}
|
||||
|
||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||
const indexKey = homeSessionIndexKey(server)
|
||||
const eventsKey = homeSessionEventsKey(server)
|
||||
let connected = false
|
||||
|
||||
return {
|
||||
indexKey,
|
||||
eventsKey,
|
||||
eventSequence() {
|
||||
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||
},
|
||||
complete(sequence: number) {
|
||||
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||
},
|
||||
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
return homeSessionIndexSessions(index, events)
|
||||
},
|
||||
apply(event: HomeSessionEvent) {
|
||||
if (!queryClient.getQueryState(indexKey)) return
|
||||
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||
queryClient.setQueryData(eventsKey, next)
|
||||
return
|
||||
}
|
||||
|
||||
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||
if (index) {
|
||||
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||
sessions: homeSessionIndexSessions(index, next),
|
||||
eventSequence: next.sequence,
|
||||
})
|
||||
}
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||
},
|
||||
refresh(event: Event["type"]) {
|
||||
const result = homeSessionIndexRefresh(event, connected)
|
||||
connected = result.connected
|
||||
if (!result.refetch) return
|
||||
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||
// multiple directories. A bounded page could omit an old session updated today.
|
||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || item.time.archived !== undefined) return []
|
||||
return [toLegacySummary(item)]
|
||||
})
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||
}
|
||||
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === info.id)
|
||||
if (event.type === "session.deleted" || info.parentID || info.time.archived !== undefined) {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||
if (index === -1) return [...sessions, info]
|
||||
return sessions.with(index, info)
|
||||
}
|
||||
|
||||
function toLegacySummary(session: SessionV2Info): Session {
|
||||
return {
|
||||
id: session.id,
|
||||
slug: session.id,
|
||||
projectID: session.projectID,
|
||||
workspaceID: session.location.workspaceID,
|
||||
directory: session.location.directory,
|
||||
path: session.subpath,
|
||||
parentID: session.parentID,
|
||||
cost: session.cost,
|
||||
tokens: session.tokens,
|
||||
title: session.title,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
version: "",
|
||||
time: session.time,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
@@ -32,7 +33,8 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -41,6 +43,7 @@ describe("app session cache", () => {
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
@@ -53,6 +56,7 @@ describe("app session cache", () => {
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
@@ -63,7 +67,8 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -72,6 +77,7 @@ describe("app session cache", () => {
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
|
||||
@@ -4,14 +4,16 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -34,6 +36,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
@@ -50,7 +51,10 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
|
||||
@@ -71,6 +71,7 @@ type SessionView = {
|
||||
reviewFile?: string
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
todoCollapsed?: boolean
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
@@ -849,6 +850,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
todoCollapsed: {
|
||||
get: () => s().todoCollapsed ?? false,
|
||||
set(collapsed: boolean) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
|
||||
} else {
|
||||
setStore("sessionView", session, "todoCollapsed", collapsed)
|
||||
}
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
|
||||
@@ -126,9 +126,8 @@ describe("enqueueServerEvent", () => {
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({
|
||||
id: "event-delete",
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session" },
|
||||
properties: { sessionID: "session", info: { id: "session" } },
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -142,6 +143,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore({
|
||||
status: "connecting" as "connecting" | "connected" | "reconnecting",
|
||||
})
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -187,6 +191,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})
|
||||
},
|
||||
})
|
||||
setConnection("status", "connected")
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
@@ -218,6 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
setConnection("status", "reconnecting")
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
@@ -269,6 +275,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
connection,
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
|
||||
@@ -151,6 +151,7 @@ function setup(sessions: Record<string, Session>) {
|
||||
return response()
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return { get, messages, store: createServerSession(client) }
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -138,7 +139,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
@@ -151,6 +153,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const requests = new Map<string, Promise<Session>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
@@ -196,6 +199,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
@@ -250,7 +254,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID)
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
@@ -413,6 +418,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
@@ -442,6 +448,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
@@ -762,10 +769,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
return
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
setData("session_status", props.sessionID, reconcile(props.status))
|
||||
@@ -1128,6 +1140,17 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
})
|
||||
})
|
||||
},
|
||||
todo(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
data.message[sessionID] !== undefined &&
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession } from "./server-session"
|
||||
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
session.apply(event)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
if (!children.active(directory)) continue
|
||||
queue.push(directory)
|
||||
}
|
||||
}
|
||||
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: queue.push,
|
||||
push: (directory) => {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
},
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
},
|
||||
loadReferences: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||
},
|
||||
})
|
||||
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -583,6 +583,9 @@ export const dict = {
|
||||
"session.messages.loading": "جارٍ تحميل الرسائل...",
|
||||
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
|
||||
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.question.minimize": "تصغير السؤال",
|
||||
"session.question.restore": "استعادة السؤال",
|
||||
"session.question.pending.one": "{{count}} سؤال معلق",
|
||||
@@ -618,6 +621,14 @@ export const dict = {
|
||||
"session.header.open.menu": "خيارات الفتح",
|
||||
"session.header.open.copyPath": "نسخ المسار",
|
||||
"status.popover.trigger": "الحالة",
|
||||
"status.popover.tools.trigger": "الأدوات",
|
||||
"status.popover.tools.mcp.description": "أضف خوادم MCP في opencode.json",
|
||||
"status.popover.tools.lsp.description": "يتم اكتشاف خوادم LSP تلقائيًا عند فتح الملفات",
|
||||
"status.popover.tools.plugins.description": "أضف الإضافات في opencode.json",
|
||||
"status.popover.tools.mcp.empty": "لم يتم إعداد أي خوادم MCP بعد",
|
||||
"status.popover.tools.lsp.empty": "لا توجد خوادم لغة نشطة بعد",
|
||||
"status.popover.tools.plugins.empty": "لم يتم إعداد أي إضافات بعد",
|
||||
"status.popover.tools.reauthenticate": "إعادة المصادقة",
|
||||
"status.popover.ariaLabel": "إعدادات الخوادم",
|
||||
"status.popover.tab.servers": "الخوادم",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -719,6 +730,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
|
||||
"settings.general.row.showStatus.title": "حالة الخادم",
|
||||
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
|
||||
"settings.general.row.showTools.title": "قائمة الأدوات",
|
||||
"settings.general.row.showTools.description": "إظهار وصول سريع إلى MCP وLSP والإضافات في شريط العنوان",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
||||
@@ -870,6 +883,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
|
||||
"settings.permissions.tool.websearch.title": "بحث الويب",
|
||||
@@ -924,6 +939,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
|
||||
"app.server.status.reconnecting": "جارٍ إعادة الاتصال بـ {{server}}...",
|
||||
"app.server.status.disconnected": "تعذر الاتصال بـ {{server}}",
|
||||
"app.server.otherServers": "خوادم أخرى",
|
||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||
@@ -932,6 +949,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
||||
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
|
||||
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
|
||||
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
|
||||
"session.question.progress": "{{current}} من {{total}} أسئلة",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "مستكشف الملفات",
|
||||
|
||||
@@ -589,6 +589,9 @@ export const dict = {
|
||||
"session.messages.loading": "Carregando mensagens...",
|
||||
"session.messages.jumpToLatest": "Ir para a mais recente",
|
||||
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pergunta",
|
||||
"session.question.restore": "Restaurar pergunta",
|
||||
"session.question.pending.one": "{{count}} pergunta pendente",
|
||||
@@ -624,6 +627,14 @@ export const dict = {
|
||||
"session.header.open.menu": "Opções de abertura",
|
||||
"session.header.open.copyPath": "Copiar caminho",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Ferramentas",
|
||||
"status.popover.tools.mcp.description": "Adicione servidores MCP no opencode.json",
|
||||
"status.popover.tools.lsp.description": "Os LSPs são detectados automaticamente ao abrir arquivos",
|
||||
"status.popover.tools.plugins.description": "Adicione plugins no opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Nenhum MCP configurado ainda",
|
||||
"status.popover.tools.lsp.empty": "Nenhum servidor de linguagem ativo ainda",
|
||||
"status.popover.tools.plugins.empty": "Nenhum plugin configurado ainda",
|
||||
"status.popover.tools.reauthenticate": "Autenticar novamente",
|
||||
"status.popover.ariaLabel": "Configurações de servidores",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -730,6 +741,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
|
||||
"settings.general.row.showStatus.title": "Status do servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
|
||||
"settings.general.row.showTools.title": "Menu de ferramentas",
|
||||
"settings.general.row.showTools.description": "Mostrar acesso rápido a MCPs, LSPs e plugins na barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
|
||||
@@ -884,6 +897,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
|
||||
"settings.permissions.tool.websearch.title": "Pesquisa Web",
|
||||
@@ -938,6 +953,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando a {{server}}...",
|
||||
"app.server.status.disconnected": "Não foi possível conectar a {{server}}",
|
||||
"app.server.otherServers": "Outros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "nome de usuário",
|
||||
"dialog.server.add.passwordPlaceholder": "senha",
|
||||
@@ -946,6 +963,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
|
||||
"session.review.noVcs.createGit.action": "Criar repositório Git",
|
||||
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
|
||||
"session.question.progress": "{{current}} de {{total}} perguntas",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorador de Arquivos",
|
||||
|
||||
@@ -644,6 +644,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Idi na najnovije",
|
||||
|
||||
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
|
||||
"session.todo.title": "Zadaci",
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
"session.question.minimize": "Minimiziraj pitanje",
|
||||
"session.question.restore": "Vrati pitanje",
|
||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||
@@ -682,6 +685,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopiraj putanju",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Alati",
|
||||
"status.popover.tools.mcp.description": "Dodajte MCP servere u opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP serveri se automatski otkrivaju dok otvarate datoteke",
|
||||
"status.popover.tools.plugins.description": "Dodajte dodatke u opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Još nema konfigurisanih MCP servera",
|
||||
"status.popover.tools.lsp.empty": "Još nema aktivnih jezičkih servera",
|
||||
"status.popover.tools.plugins.empty": "Još nema konfigurisanih dodataka",
|
||||
"status.popover.tools.reauthenticate": "Ponovo se autentificiraj",
|
||||
"status.popover.ariaLabel": "Konfiguracije servera",
|
||||
"status.popover.tab.servers": "Serveri",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -794,6 +805,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
|
||||
"settings.general.row.showStatus.title": "Status servera",
|
||||
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
|
||||
"settings.general.row.showTools.title": "Meni alata",
|
||||
"settings.general.row.showTools.description": "Prikaži brzi pristup MCP, LSP i dodacima u naslovnoj traci",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
|
||||
@@ -958,6 +971,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
|
||||
"settings.permissions.tool.websearch.title": "Web pretraga",
|
||||
@@ -1014,6 +1029,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||
"app.server.status.reconnecting": "Ponovno povezivanje sa {{server}}...",
|
||||
"app.server.status.disconnected": "Nije moguće povezati se sa {{server}}",
|
||||
"app.server.otherServers": "Drugi serveri",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
@@ -1022,6 +1039,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
|
||||
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
||||
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
|
||||
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
|
||||
"session.question.progress": "{{current}} od {{total}} pitanja",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "File Explorer",
|
||||
|
||||
@@ -639,6 +639,9 @@ export const dict = {
|
||||
|
||||
"session.messages.jumpToLatest": "Gå til seneste",
|
||||
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
|
||||
"session.todo.title": "Opgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
"session.question.minimize": "Minimer spørgsmål",
|
||||
"session.question.restore": "Gendan spørgsmål",
|
||||
"session.question.pending.one": "{{count}} afventende spørgsmål",
|
||||
@@ -677,6 +680,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopier sti",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Værktøjer",
|
||||
"status.popover.tools.mcp.description": "Tilføj MCP-servere i opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP'er registreres automatisk, når du åbner filer",
|
||||
"status.popover.tools.plugins.description": "Tilføj plugins i opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Ingen MCP'er konfigureret endnu",
|
||||
"status.popover.tools.lsp.empty": "Ingen aktive sprogservere endnu",
|
||||
"status.popover.tools.plugins.empty": "Ingen plugins konfigureret endnu",
|
||||
"status.popover.tools.reauthenticate": "Godkend igen",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationer",
|
||||
"status.popover.tab.servers": "Servere",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -788,6 +799,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
|
||||
"settings.general.row.showTools.title": "Værktøjsmenu",
|
||||
"settings.general.row.showTools.description": "Vis hurtig adgang til MCP'er, LSP'er og plugins i titellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
||||
@@ -950,6 +963,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
|
||||
"settings.permissions.tool.websearch.title": "Websøgning",
|
||||
@@ -1006,6 +1021,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver igen automatisk...",
|
||||
"app.server.status.reconnecting": "Opretter forbindelse til {{server}} igen...",
|
||||
"app.server.status.disconnected": "Kunne ikke oprette forbindelse til {{server}}",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brugernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "adgangskode",
|
||||
@@ -1014,6 +1031,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
|
||||
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
|
||||
"session.review.noVcs.createGit.action": "Opret Git-repository",
|
||||
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
|
||||
"session.question.progress": "{{current}} af {{total}} spørgsmål",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Stifinder",
|
||||
|
||||
@@ -598,6 +598,9 @@ export const dict = {
|
||||
"session.messages.loading": "Lade Nachrichten...",
|
||||
"session.messages.jumpToLatest": "Zum neuesten springen",
|
||||
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.question.minimize": "Frage minimieren",
|
||||
"session.question.restore": "Frage wiederherstellen",
|
||||
"session.question.pending.one": "{{count}} ausstehende Frage",
|
||||
@@ -633,6 +636,14 @@ export const dict = {
|
||||
"session.header.open.menu": "Öffnen-Optionen",
|
||||
"session.header.open.copyPath": "Pfad kopieren",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Werkzeuge",
|
||||
"status.popover.tools.mcp.description": "MCP-Server in opencode.json hinzufügen",
|
||||
"status.popover.tools.lsp.description": "LSPs werden beim Öffnen von Dateien automatisch erkannt",
|
||||
"status.popover.tools.plugins.description": "Plugins in opencode.json hinzufügen",
|
||||
"status.popover.tools.mcp.empty": "Noch keine MCPs konfiguriert",
|
||||
"status.popover.tools.lsp.empty": "Noch keine aktiven Sprachserver",
|
||||
"status.popover.tools.plugins.empty": "Noch keine Plugins konfiguriert",
|
||||
"status.popover.tools.reauthenticate": "Erneut authentifizieren",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationen",
|
||||
"status.popover.tab.servers": "Server",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -742,6 +753,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
|
||||
"settings.general.row.showTools.title": "Werkzeugmenü",
|
||||
"settings.general.row.showTools.description": "Schnellzugriff auf MCPs, LSPs und Plugins in der Titelleiste anzeigen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
|
||||
@@ -896,6 +909,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
|
||||
"settings.permissions.tool.websearch.title": "Web-Suche",
|
||||
@@ -950,6 +965,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
|
||||
"app.server.retrying": "Automatische erneute Verbindung...",
|
||||
"app.server.status.reconnecting": "Verbindung zu {{server}} wird wiederhergestellt...",
|
||||
"app.server.status.disconnected": "Verbindung zu {{server}} konnte nicht hergestellt werden",
|
||||
"app.server.otherServers": "Andere Server",
|
||||
"dialog.server.add.usernamePlaceholder": "Benutzername",
|
||||
"dialog.server.add.passwordPlaceholder": "Passwort",
|
||||
@@ -959,6 +976,7 @@ export const dict = {
|
||||
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
|
||||
"session.review.noVcs.createGit.action": "Git-Repository erstellen",
|
||||
"session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
|
||||
"session.question.progress": "{{current}} von {{total}} Fragen",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Datei-Explorer",
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.message.next.description": "Go to the next user message",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.choose.description": "Select a different model",
|
||||
"command.mcp.toggle": "Manage MCP servers",
|
||||
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||
"command.mcp.toggle": "Toggle MCPs",
|
||||
"command.mcp.toggle.description": "Toggle MCPs",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.agent.cycle.description": "Switch to the next agent",
|
||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||
@@ -307,9 +307,9 @@ export const dict = {
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCP servers",
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCP servers configured",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
@@ -332,6 +332,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Could not reach {{server}}",
|
||||
"app.server.retrying": "Retrying automatically...",
|
||||
"app.server.status.reconnecting": "Reconnecting to {{server}}...",
|
||||
"app.server.status.disconnected": "Couldn't connect to {{server}}",
|
||||
"app.server.otherServers": "Other servers",
|
||||
|
||||
"dialog.server.title": "Servers",
|
||||
@@ -665,6 +667,10 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Jump to latest",
|
||||
|
||||
"session.context.addToContext": "Add {{selection}} to context",
|
||||
"session.todo.title": "Todos",
|
||||
"session.todo.collapse": "Collapse",
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
@@ -724,6 +730,14 @@ export const dict = {
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.ariaLabel": "Server configurations",
|
||||
"status.popover.tools.trigger": "Tools",
|
||||
"status.popover.tools.mcp.description": "Add MCP servers in opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSPs are auto-detected as you open files",
|
||||
"status.popover.tools.plugins.description": "Add plugins in opencode.json",
|
||||
"status.popover.tools.mcp.empty": "No MCPs configured yet",
|
||||
"status.popover.tools.lsp.empty": "No active language servers yet",
|
||||
"status.popover.tools.plugins.empty": "No plugins configured yet",
|
||||
"status.popover.tools.reauthenticate": "Reauthenticate",
|
||||
"status.popover.tab.servers": "Servers",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
@@ -882,6 +896,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showTools.title": "Tools menu",
|
||||
"settings.general.row.showTools.description": "Show quick access to MCPs, LSPs, and plugins in the title bar",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
@@ -1045,6 +1061,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
||||
@@ -645,6 +645,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Ir al último",
|
||||
|
||||
"session.context.addToContext": "Añadir {{selection}} al contexto",
|
||||
"session.todo.title": "Tareas",
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pregunta",
|
||||
"session.question.restore": "Restaurar pregunta",
|
||||
"session.question.pending.one": "{{count}} pregunta pendiente",
|
||||
@@ -683,6 +686,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Copiar ruta",
|
||||
|
||||
"status.popover.trigger": "Estado",
|
||||
"status.popover.tools.trigger": "Herramientas",
|
||||
"status.popover.tools.mcp.description": "Añade servidores MCP en opencode.json",
|
||||
"status.popover.tools.lsp.description": "Los LSP se detectan automáticamente al abrir archivos",
|
||||
"status.popover.tools.plugins.description": "Añade plugins en opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aún no hay MCP configurados",
|
||||
"status.popover.tools.lsp.empty": "Aún no hay servidores de lenguaje activos",
|
||||
"status.popover.tools.plugins.empty": "Aún no hay plugins configurados",
|
||||
"status.popover.tools.reauthenticate": "Volver a autenticar",
|
||||
"status.popover.ariaLabel": "Configuraciones del servidor",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -799,6 +810,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
|
||||
"settings.general.row.showStatus.title": "Estado del servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
|
||||
"settings.general.row.showTools.title": "Menú de herramientas",
|
||||
"settings.general.row.showTools.description": "Mostrar acceso rápido a MCP, LSP y plugins en la barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
|
||||
@@ -966,6 +979,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
|
||||
"settings.permissions.tool.websearch.title": "Búsqueda Web",
|
||||
@@ -1022,6 +1037,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "No se pudo conectar con {{server}}",
|
||||
"app.server.retrying": "Reintentando automáticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando con {{server}}...",
|
||||
"app.server.status.disconnected": "No se pudo conectar con {{server}}",
|
||||
"app.server.otherServers": "Otros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "usuario",
|
||||
"dialog.server.add.passwordPlaceholder": "contraseña",
|
||||
@@ -1030,6 +1047,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
|
||||
"session.review.noVcs.createGit.action": "Crear repositorio Git",
|
||||
"session.todo.progress": "{{done}} de {{total}} tareas completadas",
|
||||
"session.question.progress": "{{current}} de {{total}} preguntas",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorador de archivos",
|
||||
|
||||
@@ -594,6 +594,9 @@ export const dict = {
|
||||
"session.messages.loading": "Chargement des messages...",
|
||||
"session.messages.jumpToLatest": "Aller au dernier",
|
||||
"session.context.addToContext": "Ajouter {{selection}} au contexte",
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.question.minimize": "Réduire la question",
|
||||
"session.question.restore": "Restaurer la question",
|
||||
"session.question.pending.one": "{{count}} question en attente",
|
||||
@@ -629,6 +632,14 @@ export const dict = {
|
||||
"session.header.open.menu": "Options d'ouverture",
|
||||
"session.header.open.copyPath": "Copier le chemin",
|
||||
"status.popover.trigger": "Statut",
|
||||
"status.popover.tools.trigger": "Outils",
|
||||
"status.popover.tools.mcp.description": "Ajouter des serveurs MCP dans opencode.json",
|
||||
"status.popover.tools.lsp.description": "Les LSP sont détectés automatiquement à l’ouverture des fichiers",
|
||||
"status.popover.tools.plugins.description": "Ajouter des plugins dans opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aucun MCP configuré pour le moment",
|
||||
"status.popover.tools.lsp.empty": "Aucun serveur de langage actif pour le moment",
|
||||
"status.popover.tools.plugins.empty": "Aucun plugin configuré pour le moment",
|
||||
"status.popover.tools.reauthenticate": "S’authentifier à nouveau",
|
||||
"status.popover.ariaLabel": "Configurations des serveurs",
|
||||
"status.popover.tab.servers": "Serveurs",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -739,6 +750,9 @@ export const dict = {
|
||||
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
|
||||
"settings.general.row.showStatus.title": "État du serveur",
|
||||
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
|
||||
"settings.general.row.showTools.title": "Menu Outils",
|
||||
"settings.general.row.showTools.description":
|
||||
"Afficher un accès rapide aux MCP, LSP et plugins dans la barre de titre",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
|
||||
@@ -895,6 +909,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
|
||||
"settings.permissions.tool.websearch.title": "Recherche Web",
|
||||
@@ -949,6 +965,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Impossible de joindre {{server}}",
|
||||
"app.server.retrying": "Nouvelle tentative automatique...",
|
||||
"app.server.status.reconnecting": "Reconnexion à {{server}}...",
|
||||
"app.server.status.disconnected": "Impossible de se connecter à {{server}}",
|
||||
"app.server.otherServers": "Autres serveurs",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
|
||||
"dialog.server.add.passwordPlaceholder": "mot de passe",
|
||||
@@ -957,6 +975,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
|
||||
"session.review.noVcs.createGit.action": "Créer un dépôt Git",
|
||||
"session.todo.progress": "{{done}} tâches sur {{total}} terminées",
|
||||
"session.question.progress": "{{current}} questions sur {{total}}",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorateur de fichiers",
|
||||
|
||||
@@ -585,6 +585,9 @@ export const dict = {
|
||||
"session.messages.loading": "メッセージを読み込み中...",
|
||||
"session.messages.jumpToLatest": "最新へジャンプ",
|
||||
"session.context.addToContext": "{{selection}}をコンテキストに追加",
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.question.minimize": "質問を最小化",
|
||||
"session.question.restore": "質問を復元",
|
||||
"session.question.pending.one": "{{count}}件の保留中の質問",
|
||||
@@ -620,6 +623,14 @@ export const dict = {
|
||||
"session.header.open.menu": "開くオプション",
|
||||
"session.header.open.copyPath": "パスをコピー",
|
||||
"status.popover.trigger": "ステータス",
|
||||
"status.popover.tools.trigger": "ツール",
|
||||
"status.popover.tools.mcp.description": "opencode.json に MCP サーバーを追加",
|
||||
"status.popover.tools.lsp.description": "ファイルを開くと LSP が自動検出されます",
|
||||
"status.popover.tools.plugins.description": "opencode.json にプラグインを追加",
|
||||
"status.popover.tools.mcp.empty": "MCP はまだ設定されていません",
|
||||
"status.popover.tools.lsp.empty": "アクティブな言語サーバーはまだありません",
|
||||
"status.popover.tools.plugins.empty": "プラグインはまだ設定されていません",
|
||||
"status.popover.tools.reauthenticate": "再認証",
|
||||
"status.popover.ariaLabel": "サーバー設定",
|
||||
"status.popover.tab.servers": "サーバー",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -725,6 +736,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します",
|
||||
"settings.general.row.showStatus.title": "サーバーステータス",
|
||||
"settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します",
|
||||
"settings.general.row.showTools.title": "ツールメニュー",
|
||||
"settings.general.row.showTools.description": "タイトルバーに MCP、LSP、プラグインへのクイックアクセスを表示します",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
|
||||
@@ -877,6 +890,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
|
||||
"settings.permissions.tool.websearch.title": "Web検索",
|
||||
@@ -931,6 +946,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}} に到達できませんでした",
|
||||
"app.server.retrying": "自動的に再試行中...",
|
||||
"app.server.status.reconnecting": "{{server}} に再接続しています...",
|
||||
"app.server.status.disconnected": "{{server}} に接続できませんでした",
|
||||
"app.server.otherServers": "その他のサーバー",
|
||||
"dialog.server.add.usernamePlaceholder": "ユーザー名",
|
||||
"dialog.server.add.passwordPlaceholder": "パスワード",
|
||||
@@ -939,6 +956,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
|
||||
"session.review.noVcs.createGit.action": "Git リポジトリを作成",
|
||||
"session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了",
|
||||
"session.question.progress": "{{total}} 問中 {{current}} 問",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "エクスプローラー",
|
||||
|
||||
@@ -476,6 +476,9 @@ export const dict = {
|
||||
"session.messages.loading": "메시지 로드 중...",
|
||||
"session.messages.jumpToLatest": "최신으로 이동",
|
||||
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.sendNow": "지금 전송",
|
||||
@@ -500,6 +503,14 @@ export const dict = {
|
||||
"session.header.open.menu": "열기 옵션",
|
||||
"session.header.open.copyPath": "경로 복사",
|
||||
"status.popover.trigger": "상태",
|
||||
"status.popover.tools.trigger": "도구",
|
||||
"status.popover.tools.mcp.description": "opencode.json에 MCP 서버 추가",
|
||||
"status.popover.tools.lsp.description": "파일을 열면 LSP가 자동으로 감지됩니다",
|
||||
"status.popover.tools.plugins.description": "opencode.json에 플러그인 추가",
|
||||
"status.popover.tools.mcp.empty": "아직 구성된 MCP가 없습니다",
|
||||
"status.popover.tools.lsp.empty": "아직 활성 언어 서버가 없습니다",
|
||||
"status.popover.tools.plugins.empty": "아직 구성된 플러그인이 없습니다",
|
||||
"status.popover.tools.reauthenticate": "다시 인증",
|
||||
"status.popover.ariaLabel": "서버 구성",
|
||||
"status.popover.tab.servers": "서버",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -723,6 +734,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
|
||||
"settings.permissions.tool.websearch.title": "웹 검색",
|
||||
@@ -777,6 +790,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.retrying": "자동으로 재시도 중...",
|
||||
"app.server.status.reconnecting": "{{server}}에 다시 연결하는 중...",
|
||||
"app.server.status.disconnected": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.otherServers": "다른 서버",
|
||||
"dialog.server.add.usernamePlaceholder": "사용자 이름",
|
||||
"dialog.server.add.passwordPlaceholder": "비밀번호",
|
||||
@@ -785,6 +800,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
|
||||
"session.review.noVcs.createGit.action": "Git 저장소 생성",
|
||||
"session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료",
|
||||
"session.question.progress": "{{total}}개의 질문 중 {{current}}개",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "파일 탐색기",
|
||||
@@ -989,6 +1005,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시",
|
||||
"settings.general.row.showStatus.title": "서버 상태",
|
||||
"settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시",
|
||||
"settings.general.row.showTools.title": "도구 메뉴",
|
||||
"settings.general.row.showTools.description": "제목 표시줄에 MCP, LSP 및 플러그인 빠른 접근 표시",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "하단 탐색",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
||||
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
||||
|
||||
@@ -537,6 +537,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Hopp til nyeste",
|
||||
|
||||
"session.context.addToContext": "Legg til {{selection}} i kontekst",
|
||||
"session.todo.title": "Oppgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Utvid",
|
||||
"session.followupDock.summary.one": "{{count}} melding i kø",
|
||||
"session.followupDock.summary.other": "{{count}} meldinger i kø",
|
||||
"session.followupDock.sendNow": "Send nå",
|
||||
@@ -564,6 +567,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopier bane",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Verktøy",
|
||||
"status.popover.tools.mcp.description": "Legg til MCP-servere i opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP-er oppdages automatisk når du åpner filer",
|
||||
"status.popover.tools.plugins.description": "Legg til plugins i opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Ingen MCP-er konfigurert ennå",
|
||||
"status.popover.tools.lsp.empty": "Ingen aktive språkservere ennå",
|
||||
"status.popover.tools.plugins.empty": "Ingen plugins konfigurert ennå",
|
||||
"status.popover.tools.reauthenticate": "Autentiser på nytt",
|
||||
"status.popover.ariaLabel": "Serverkonfigurasjoner",
|
||||
"status.popover.tab.servers": "Servere",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -808,6 +819,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
|
||||
"settings.permissions.tool.websearch.title": "Websøk",
|
||||
@@ -864,6 +877,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver på nytt automatisk...",
|
||||
"app.server.status.reconnecting": "Kobler til {{server}} på nytt...",
|
||||
"app.server.status.disconnected": "Kunne ikke koble til {{server}}",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brukernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "passord",
|
||||
@@ -872,6 +887,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...",
|
||||
"session.review.noVcs.createGit.action": "Opprett Git-depot",
|
||||
"session.todo.progress": "{{done}} av {{total}} oppgaver fullført",
|
||||
"session.question.progress": "{{current}} av {{total}} spørsmål",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Filutforsker",
|
||||
@@ -1084,6 +1100,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen på tittellinjen i skrivebordsappen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen på tittellinjen",
|
||||
"settings.general.row.showTools.title": "Verktøymeny",
|
||||
"settings.general.row.showTools.description": "Vis hurtigtilgang til MCP-er, LSP-er og plugins på tittellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigasjon nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
|
||||
|
||||
@@ -589,6 +589,9 @@ export const dict = {
|
||||
"session.messages.loading": "Ładowanie wiadomości...",
|
||||
"session.messages.jumpToLatest": "Przejdź do najnowszych",
|
||||
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.question.minimize": "Zminimalizuj pytanie",
|
||||
"session.question.restore": "Przywróć pytanie",
|
||||
"session.question.pending.one": "{{count}} oczekujące pytanie",
|
||||
@@ -624,6 +627,14 @@ export const dict = {
|
||||
"session.header.open.menu": "Opcje otwierania",
|
||||
"session.header.open.copyPath": "Kopiuj ścieżkę",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Narzędzia",
|
||||
"status.popover.tools.mcp.description": "Dodaj serwery MCP w opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP są wykrywane automatycznie podczas otwierania plików",
|
||||
"status.popover.tools.plugins.description": "Dodaj wtyczki w opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Nie skonfigurowano jeszcze MCP",
|
||||
"status.popover.tools.lsp.empty": "Brak aktywnych serwerów językowych",
|
||||
"status.popover.tools.plugins.empty": "Nie skonfigurowano jeszcze wtyczek",
|
||||
"status.popover.tools.reauthenticate": "Uwierzytelnij ponownie",
|
||||
"status.popover.ariaLabel": "Konfiguracje serwerów",
|
||||
"status.popover.tab.servers": "Serwery",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -730,6 +741,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Pokaż przycisk terminala na pasku tytułu aplikacji komputerowej",
|
||||
"settings.general.row.showStatus.title": "Status serwera",
|
||||
"settings.general.row.showStatus.description": "Pokaż przycisk statusu serwera na pasku tytułu",
|
||||
"settings.general.row.showTools.title": "Menu narzędzi",
|
||||
"settings.general.row.showTools.description": "Pokaż szybki dostęp do MCP, LSP i wtyczek na pasku tytułu",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Dolna nawigacja",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Umieść pasek tytułu i karty sesji u dołu ekranu na urządzeniach mobilnych",
|
||||
@@ -883,6 +896,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
|
||||
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
|
||||
@@ -937,6 +952,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Nie można połączyć z {{server}}",
|
||||
"app.server.retrying": "Ponawianie automatycznie...",
|
||||
"app.server.status.reconnecting": "Ponowne łączenie z {{server}}...",
|
||||
"app.server.status.disconnected": "Nie można połączyć z {{server}}",
|
||||
"app.server.otherServers": "Inne serwery",
|
||||
"dialog.server.add.usernamePlaceholder": "nazwa użytkownika",
|
||||
"dialog.server.add.passwordPlaceholder": "hasło",
|
||||
@@ -945,6 +962,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie",
|
||||
"session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...",
|
||||
"session.review.noVcs.createGit.action": "Utwórz repozytorium Git",
|
||||
"session.todo.progress": "Ukończono {{done}} z {{total}} zadań",
|
||||
"session.question.progress": "{{current}} z {{total}} pytań",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Eksplorator plików",
|
||||
|
||||
@@ -642,6 +642,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Перейти к последнему",
|
||||
|
||||
"session.context.addToContext": "Добавить {{selection}} в контекст",
|
||||
"session.todo.title": "Задачи",
|
||||
"session.todo.collapse": "Свернуть",
|
||||
"session.todo.expand": "Развернуть",
|
||||
"session.question.minimize": "Свернуть вопрос",
|
||||
"session.question.restore": "Восстановить вопрос",
|
||||
"session.question.pending.one": "{{count}} вопрос без ответа",
|
||||
@@ -680,6 +683,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Копировать путь",
|
||||
|
||||
"status.popover.trigger": "Статус",
|
||||
"status.popover.tools.trigger": "Инструменты",
|
||||
"status.popover.tools.mcp.description": "Добавьте серверы MCP в opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP определяются автоматически при открытии файлов",
|
||||
"status.popover.tools.plugins.description": "Добавьте плагины в opencode.json",
|
||||
"status.popover.tools.mcp.empty": "MCP пока не настроены",
|
||||
"status.popover.tools.lsp.empty": "Активных языковых серверов пока нет",
|
||||
"status.popover.tools.plugins.empty": "Плагины пока не настроены",
|
||||
"status.popover.tools.reauthenticate": "Повторно авторизоваться",
|
||||
"status.popover.ariaLabel": "Настройки серверов",
|
||||
"status.popover.tab.servers": "Серверы",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -795,6 +806,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Показывать кнопку терминала в строке заголовка приложения",
|
||||
"settings.general.row.showStatus.title": "Состояние сервера",
|
||||
"settings.general.row.showStatus.description": "Показывать кнопку состояния сервера в строке заголовка",
|
||||
"settings.general.row.showTools.title": "Меню инструментов",
|
||||
"settings.general.row.showTools.description": "Показывать быстрый доступ к MCP, LSP и плагинам в строке заголовка",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Нижняя навигация",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана",
|
||||
@@ -960,6 +973,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
@@ -1017,6 +1032,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Не удалось связаться с {{server}}",
|
||||
"app.server.retrying": "Автоматическая повторная попытка...",
|
||||
"app.server.status.reconnecting": "Повторное подключение к {{server}}...",
|
||||
"app.server.status.disconnected": "Не удалось подключиться к {{server}}",
|
||||
"app.server.otherServers": "Другие серверы",
|
||||
"dialog.server.add.usernamePlaceholder": "имя пользователя",
|
||||
"dialog.server.add.passwordPlaceholder": "пароль",
|
||||
@@ -1025,6 +1042,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте",
|
||||
"session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...",
|
||||
"session.review.noVcs.createGit.action": "Создать репозиторий Git",
|
||||
"session.todo.progress": "Выполнено {{done}} из {{total}} задач",
|
||||
"session.question.progress": "{{current}} из {{total}} вопросов",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Проводник",
|
||||
|
||||
@@ -638,6 +638,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
|
||||
|
||||
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
|
||||
"session.todo.title": "สิ่งที่ต้องทำ",
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
"session.question.minimize": "ย่อคำถาม",
|
||||
"session.question.restore": "คืนค่าคำถาม",
|
||||
"session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ",
|
||||
@@ -676,6 +679,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "คัดลอกเส้นทาง",
|
||||
|
||||
"status.popover.trigger": "สถานะ",
|
||||
"status.popover.tools.trigger": "เครื่องมือ",
|
||||
"status.popover.tools.mcp.description": "เพิ่มเซิร์ฟเวอร์ MCP ใน opencode.json",
|
||||
"status.popover.tools.lsp.description": "ระบบจะตรวจพบ LSP อัตโนมัติเมื่อคุณเปิดไฟล์",
|
||||
"status.popover.tools.plugins.description": "เพิ่มปลั๊กอินใน opencode.json",
|
||||
"status.popover.tools.mcp.empty": "ยังไม่ได้กำหนดค่า MCP",
|
||||
"status.popover.tools.lsp.empty": "ยังไม่มีเซิร์ฟเวอร์ภาษาที่ใช้งานอยู่",
|
||||
"status.popover.tools.plugins.empty": "ยังไม่ได้กำหนดค่าปลั๊กอิน",
|
||||
"status.popover.tools.reauthenticate": "ยืนยันตัวตนอีกครั้ง",
|
||||
"status.popover.ariaLabel": "การกำหนดค่าเซิร์ฟเวอร์",
|
||||
"status.popover.tab.servers": "เซิร์ฟเวอร์",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -786,6 +797,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "แสดงปุ่มเทอร์มินัลในแถบชื่อเรื่องเดสก์ท็อป",
|
||||
"settings.general.row.showStatus.title": "สถานะเซิร์ฟเวอร์",
|
||||
"settings.general.row.showStatus.description": "แสดงปุ่มสถานะเซิร์ฟเวอร์ในแถบชื่อเรื่อง",
|
||||
"settings.general.row.showTools.title": "เมนูเครื่องมือ",
|
||||
"settings.general.row.showTools.description": "แสดงทางลัดไปยัง MCP, LSP และปลั๊กอินในแถบชื่อเรื่อง",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "การนำทางด้านล่าง",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่",
|
||||
@@ -945,6 +958,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
|
||||
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
|
||||
@@ -1001,6 +1016,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "ไม่สามารถติดต่อ {{server}}",
|
||||
"app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...",
|
||||
"app.server.status.reconnecting": "กำลังเชื่อมต่อกับ {{server}} อีกครั้ง...",
|
||||
"app.server.status.disconnected": "ไม่สามารถเชื่อมต่อกับ {{server}}",
|
||||
"app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ",
|
||||
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
|
||||
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
|
||||
@@ -1009,6 +1026,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
|
||||
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...",
|
||||
"session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี",
|
||||
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
|
||||
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "File Explorer",
|
||||
|
||||
@@ -648,6 +648,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "En sona atla",
|
||||
|
||||
"session.context.addToContext": "{{selection}} bağlama ekle",
|
||||
"session.todo.title": "Görevler",
|
||||
"session.todo.collapse": "Daralt",
|
||||
"session.todo.expand": "Genişlet",
|
||||
"session.question.minimize": "Soruyu küçült",
|
||||
"session.question.restore": "Soruyu geri yükle",
|
||||
"session.question.pending.one": "{{count}} bekleyen soru",
|
||||
@@ -686,6 +689,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Yolu kopyala",
|
||||
|
||||
"status.popover.trigger": "Durum",
|
||||
"status.popover.tools.trigger": "Araçlar",
|
||||
"status.popover.tools.mcp.description": "opencode.json dosyasına MCP sunucuları ekleyin",
|
||||
"status.popover.tools.lsp.description": "Dosyaları açtığınızda LSP'ler otomatik olarak algılanır",
|
||||
"status.popover.tools.plugins.description": "opencode.json dosyasına eklentiler ekleyin",
|
||||
"status.popover.tools.mcp.empty": "Henüz yapılandırılmış MCP yok",
|
||||
"status.popover.tools.lsp.empty": "Henüz etkin dil sunucusu yok",
|
||||
"status.popover.tools.plugins.empty": "Henüz yapılandırılmış eklenti yok",
|
||||
"status.popover.tools.reauthenticate": "Yeniden kimlik doğrula",
|
||||
"status.popover.ariaLabel": "Sunucu yapılandırmaları",
|
||||
"status.popover.tab.servers": "Sunucular",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -800,6 +811,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Masaüstü başlık çubuğunda terminal düğmesini göster",
|
||||
"settings.general.row.showStatus.title": "Sunucu durumu",
|
||||
"settings.general.row.showStatus.description": "Başlık çubuğunda sunucu durumu düğmesini göster",
|
||||
"settings.general.row.showTools.title": "Araçlar menüsü",
|
||||
"settings.general.row.showTools.description": "Başlık çubuğunda MCP, LSP ve eklentilere hızlı erişim göster",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Alt gezinme",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Mobil cihazlarda başlık çubuğunu ve oturum sekmelerini ekranın altına yerleştir",
|
||||
@@ -965,6 +978,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
|
||||
"settings.permissions.tool.websearch.title": "Web Ara",
|
||||
@@ -1021,6 +1036,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}} sunucusuna ulaşılamadı",
|
||||
"app.server.retrying": "Otomatik olarak tekrar deneniyor...",
|
||||
"app.server.status.reconnecting": "{{server}} sunucusuna yeniden bağlanılıyor...",
|
||||
"app.server.status.disconnected": "{{server}} sunucusuna bağlanılamadı",
|
||||
"app.server.otherServers": "Diğer sunucular",
|
||||
"dialog.server.add.usernamePlaceholder": "kullanıcı adı",
|
||||
"dialog.server.add.passwordPlaceholder": "parola",
|
||||
@@ -1029,6 +1046,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...",
|
||||
"session.review.noVcs.createGit.action": "Git deposu oluştur",
|
||||
"session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı",
|
||||
"session.question.progress": "{{total}} sorunun {{current}} tanesi",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Dosya Gezgini",
|
||||
|
||||
@@ -333,6 +333,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Не вдалося досягти {{server}}",
|
||||
"app.server.retrying": "Автоматичне повторення...",
|
||||
"app.server.status.reconnecting": "Повторне підключення до {{server}}...",
|
||||
"app.server.status.disconnected": "Не вдалося підключитися до {{server}}",
|
||||
"app.server.otherServers": "Інші сервери",
|
||||
|
||||
"dialog.server.title": "Сервери",
|
||||
@@ -670,6 +672,10 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Перейти до останніх",
|
||||
|
||||
"session.context.addToContext": "Додати {{selection}} до контексту",
|
||||
"session.todo.title": "Завдання",
|
||||
"session.todo.collapse": "Згорнути",
|
||||
"session.todo.expand": "Розгорнути",
|
||||
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
|
||||
"session.question.progress": "{{current}} з {{total}} запитань",
|
||||
"session.question.minimize": "Згорнути запитання",
|
||||
"session.question.restore": "Відновити запитання",
|
||||
@@ -725,6 +731,14 @@ export const dict = {
|
||||
"session.header.open.app.sublimeText": "Sublime Text",
|
||||
|
||||
"status.popover.trigger": "Статус",
|
||||
"status.popover.tools.trigger": "Інструменти",
|
||||
"status.popover.tools.mcp.description": "Додайте сервери MCP в opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP визначаються автоматично під час відкриття файлів",
|
||||
"status.popover.tools.plugins.description": "Додайте плагіни в opencode.json",
|
||||
"status.popover.tools.mcp.empty": "MCP ще не налаштовано",
|
||||
"status.popover.tools.lsp.empty": "Активних мовних серверів ще немає",
|
||||
"status.popover.tools.plugins.empty": "Плагіни ще не налаштовано",
|
||||
"status.popover.tools.reauthenticate": "Повторно автентифікуватися",
|
||||
"status.popover.ariaLabel": "Конфігурації серверів",
|
||||
"status.popover.tab.servers": "Сервери",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -885,6 +899,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
|
||||
"settings.general.row.showStatus.title": "Статус сервера",
|
||||
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
|
||||
"settings.general.row.showTools.title": "Меню інструментів",
|
||||
"settings.general.row.showTools.description": "Показувати швидкий доступ до MCP, LSP і плагінів у заголовку",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Нижня навігація",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях",
|
||||
@@ -1050,6 +1066,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
||||
@@ -636,6 +636,9 @@ export const dict = {
|
||||
"session.messages.loading": "正在加载消息...",
|
||||
"session.messages.jumpToLatest": "跳转到最新",
|
||||
"session.context.addToContext": "将 {{selection}} 添加到上下文",
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.question.minimize": "最小化问题",
|
||||
"session.question.restore": "恢复问题",
|
||||
"session.question.pending.one": "{{count}} 个待处理问题",
|
||||
@@ -672,6 +675,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "复制路径",
|
||||
|
||||
"status.popover.trigger": "状态",
|
||||
"status.popover.tools.trigger": "工具",
|
||||
"status.popover.tools.mcp.description": "在 opencode.json 中添加 MCP 服务器",
|
||||
"status.popover.tools.lsp.description": "打开文件时会自动检测 LSP",
|
||||
"status.popover.tools.plugins.description": "在 opencode.json 中添加插件",
|
||||
"status.popover.tools.mcp.empty": "尚未配置 MCP",
|
||||
"status.popover.tools.lsp.empty": "尚无活动的语言服务器",
|
||||
"status.popover.tools.plugins.empty": "尚未配置插件",
|
||||
"status.popover.tools.reauthenticate": "重新认证",
|
||||
"status.popover.ariaLabel": "服务器配置",
|
||||
"status.popover.tab.servers": "服务器",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -783,6 +794,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "在桌面版标题栏中显示终端按钮",
|
||||
"settings.general.row.showStatus.title": "服务器状态",
|
||||
"settings.general.row.showStatus.description": "在标题栏中显示服务器状态按钮",
|
||||
"settings.general.row.showTools.title": "工具菜单",
|
||||
"settings.general.row.showTools.description": "在标题栏中显示 MCP、LSP 和插件的快捷入口",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "底部导航",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "在移动设备上将标题栏和会话标签页置于屏幕底部",
|
||||
"settings.general.row.showCustomAgents.title": "自定义智能体",
|
||||
@@ -938,6 +951,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||
"settings.permissions.tool.webfetch.description": "从 URL 获取内容",
|
||||
"settings.permissions.tool.websearch.title": "网页搜索",
|
||||
@@ -994,6 +1009,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "无法连接到 {{server}}",
|
||||
"app.server.retrying": "正在自动重试...",
|
||||
"app.server.status.reconnecting": "正在重新连接到 {{server}}...",
|
||||
"app.server.status.disconnected": "无法连接到 {{server}}",
|
||||
"app.server.otherServers": "其他服务器",
|
||||
"dialog.server.add.usernamePlaceholder": "用户名",
|
||||
"dialog.server.add.passwordPlaceholder": "密码",
|
||||
@@ -1002,6 +1019,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改",
|
||||
"session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...",
|
||||
"session.review.noVcs.createGit.action": "创建 Git 仓库",
|
||||
"session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)",
|
||||
"session.question.progress": "{{current}}/{{total}} 个问题",
|
||||
"session.header.open.finder": "访达",
|
||||
"session.header.open.fileExplorer": "文件资源管理器",
|
||||
|
||||
@@ -632,6 +632,9 @@ export const dict = {
|
||||
|
||||
"session.messages.jumpToLatest": "跳到最新",
|
||||
"session.context.addToContext": "將 {{selection}} 新增到上下文",
|
||||
"session.todo.title": "待辦事項",
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
"session.question.minimize": "最小化問題",
|
||||
"session.question.restore": "還原問題",
|
||||
"session.question.pending.one": "{{count}} 個待處理問題",
|
||||
@@ -670,6 +673,14 @@ export const dict = {
|
||||
"session.header.open.copyPath": "複製路徑",
|
||||
|
||||
"status.popover.trigger": "狀態",
|
||||
"status.popover.tools.trigger": "工具",
|
||||
"status.popover.tools.mcp.description": "在 opencode.json 中新增 MCP 伺服器",
|
||||
"status.popover.tools.lsp.description": "開啟檔案時會自動偵測 LSP",
|
||||
"status.popover.tools.plugins.description": "在 opencode.json 中新增外掛程式",
|
||||
"status.popover.tools.mcp.empty": "尚未設定 MCP",
|
||||
"status.popover.tools.lsp.empty": "尚無作用中的語言伺服器",
|
||||
"status.popover.tools.plugins.empty": "尚未設定外掛程式",
|
||||
"status.popover.tools.reauthenticate": "重新驗證",
|
||||
"status.popover.ariaLabel": "伺服器設定",
|
||||
"status.popover.tab.servers": "伺服器",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -778,6 +789,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "在桌面標題列中顯示終端機按鈕",
|
||||
"settings.general.row.showStatus.title": "伺服器狀態",
|
||||
"settings.general.row.showStatus.description": "在標題列中顯示伺服器狀態按鈕",
|
||||
"settings.general.row.showTools.title": "工具選單",
|
||||
"settings.general.row.showTools.description": "在標題列中顯示 MCP、LSP 和外掛程式的快速入口",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "底部導覽",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "在行動裝置上將標題列和工作階段分頁置於畫面底部",
|
||||
"settings.general.row.showCustomAgents.title": "自訂代理程式",
|
||||
@@ -934,6 +947,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "從 URL 取得內容",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
@@ -990,6 +1005,8 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "無法連線至 {{server}}",
|
||||
"app.server.retrying": "正在自動重試...",
|
||||
"app.server.status.reconnecting": "正在重新連線至 {{server}}...",
|
||||
"app.server.status.disconnected": "無法連線至 {{server}}",
|
||||
"app.server.otherServers": "其他伺服器",
|
||||
"dialog.server.add.usernamePlaceholder": "使用者名稱",
|
||||
"dialog.server.add.passwordPlaceholder": "密碼",
|
||||
@@ -998,6 +1015,7 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更",
|
||||
"session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...",
|
||||
"session.review.noVcs.createGit.action": "建立 Git 儲存庫",
|
||||
"session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)",
|
||||
"session.question.progress": "{{current}}/{{total}} 個問題",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "檔案總管",
|
||||
|
||||
@@ -40,7 +40,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
|
||||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sync: Pick<ServerSync, "child">
|
||||
sessions: () => Session[]
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
return [
|
||||
...new Map(
|
||||
input
|
||||
.projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
|
||||
).values(),
|
||||
]
|
||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const project = projectForSession(session, input.projects(), input.projectByID())
|
||||
const directory = pathKey(session.directory)
|
||||
const project =
|
||||
input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projectForSession(session, input.projects(), input.projectByID())
|
||||
if (!project) return []
|
||||
return {
|
||||
session,
|
||||
@@ -240,8 +246,8 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||
}
|
||||
|
||||
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
|
||||
// background without navigating, matching browser conventions.
|
||||
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||
// tab in the background without navigating, matching browser conventions.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
return shouldOpenSessionInBackground({
|
||||
button: event.button,
|
||||
@@ -286,6 +292,7 @@ export function NewHome() {
|
||||
return global.ensureServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const homeSessions = () => focusedSync().homeSessions
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const recentlyClosed = createMemo(
|
||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||
@@ -318,24 +325,47 @@ export function NewHome() {
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const sessionEventLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().eventsKey,
|
||||
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
|
||||
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
||||
enabled: false,
|
||||
}))
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
await Promise.all(
|
||||
projectDirectories().map((directory) =>
|
||||
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
|
||||
),
|
||||
queryKey: homeSessions().indexKey,
|
||||
enabled: !!focusedServerCtx(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
return null
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sync: focusedSync(),
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects,
|
||||
projectByID,
|
||||
@@ -363,8 +393,7 @@ export function NewHome() {
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
|
||||
void directory.session
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() => {
|
||||
return Promise.all(
|
||||
@@ -466,8 +495,8 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
void import("@/components/dialog-edit-project").then((x) => {
|
||||
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
|
||||
void import("@/components/dialog-edit-project-v2").then((x) => {
|
||||
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -484,7 +513,13 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||
const project = projectForSession(session, projects(), projectByID())
|
||||
const directoryKey = pathKey(session.directory)
|
||||
const project =
|
||||
projects().find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
) ?? projectForSession(session, projects(), projectByID())
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
@@ -1387,7 +1422,15 @@ function HomeSessionSearchResultRow(props: {
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onSelect(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
@@ -1447,7 +1490,15 @@ function HomeSessionRow(props: {
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.openSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
@@ -25,7 +22,6 @@ import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
@@ -86,7 +82,6 @@ export default function NewSessionPage() {
|
||||
])
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
@@ -126,17 +121,6 @@ export default function NewSessionPage() {
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={settings.visibility.status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
|
||||
@@ -618,6 +618,8 @@ export default function Page() {
|
||||
})
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
@@ -871,6 +873,41 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk().directory !== dir || params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -895,7 +932,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
@@ -1978,6 +2015,8 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
@@ -1992,7 +2031,12 @@ export default function Page() {
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
},
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptInputState } from "@/components/prompt-input"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
@@ -23,7 +26,12 @@ export function createSessionComposerRegionController(input: {
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
prompt: PromptInputState
|
||||
ready: Accessor<boolean>
|
||||
centered: Accessor<boolean>
|
||||
todo: {
|
||||
collapsed: Accessor<boolean>
|
||||
onToggle: () => void
|
||||
}
|
||||
followup: Accessor<SessionComposerFollowupDock | undefined>
|
||||
revert: Accessor<SessionComposerRevertDock | undefined>
|
||||
onResponseSubmit: () => void
|
||||
@@ -32,6 +40,41 @@ export function createSessionComposerRegionController(input: {
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
ready: input.ready() || input.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
timer = undefined
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
input.sessionKey()
|
||||
const ready = input.ready()
|
||||
const dock = input.state.dock()
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, 140)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
@@ -49,10 +92,27 @@ export function createSessionComposerRegionController(input: {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(el, update)
|
||||
update()
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.get(id)?.parentID : undefined
|
||||
})
|
||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${input.sessionKey()}\0${store.ready}`,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
@@ -62,6 +122,7 @@ export function createSessionComposerRegionController(input: {
|
||||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
todo: input.todo,
|
||||
followup: input.followup,
|
||||
revert: input.revert,
|
||||
onResponseSubmit: input.onResponseSubmit,
|
||||
@@ -73,7 +134,11 @@ export function createSessionComposerRegionController(input: {
|
||||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 0),
|
||||
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
|
||||
dockProgress: value,
|
||||
dockHeight: () => Math.max(78, store.height),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
|
||||
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
@@ -59,6 +60,28 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={controller.showComposer()}>
|
||||
<Show when={controller.dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": controller.dockProgress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={controller.setDockBodyRef}>
|
||||
<SessionTodoDock
|
||||
todos={controller.state.todos()}
|
||||
collapsed={controller.todo.collapsed()}
|
||||
onToggle={controller.todo.onToggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={controller.dockProgress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
@@ -75,7 +98,10 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
|
||||
>
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
@@ -83,7 +109,11 @@ export function SessionComposerRegion(props: {
|
||||
>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
"margin-top": `${-36 * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionRevertDock
|
||||
items={revert.items}
|
||||
restoring={revert.restoring}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
@@ -103,3 +104,35 @@ describe("sessionQuestionRequest", () => {
|
||||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoState", () => {
|
||||
test("hides when there are no todos", () => {
|
||||
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
|
||||
})
|
||||
|
||||
test("opens while the session is still working", () => {
|
||||
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
|
||||
})
|
||||
|
||||
test("closes completed todos after a running turn", () => {
|
||||
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
|
||||
})
|
||||
|
||||
test("clears stale todos when the turn ends", () => {
|
||||
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
|
||||
})
|
||||
|
||||
test("clears completed todos when the session is no longer live", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoDockAtBoundary", () => {
|
||||
test("shows active todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("open")).toBe(true)
|
||||
})
|
||||
|
||||
test("hides completed todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("close")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
|
||||
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerController() {
|
||||
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
|
||||
@@ -34,8 +49,24 @@ export function createSessionComposerController() {
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync().session.data.todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && !done() && live(),
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
@@ -61,12 +92,112 @@ export function createSessionComposerController() {
|
||||
})
|
||||
}
|
||||
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
// Keep stale turn todos from reopening if the model never clears them.
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
([id, count, complete, active], previous) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
const next = todoState({
|
||||
count,
|
||||
done: complete,
|
||||
live: active,
|
||||
})
|
||||
|
||||
if (!previous || previous[0] !== id) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||
if (next === "clear") clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "hide") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ dock: false, closing: false, opening: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "clear") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "open") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const hidden = !store.dock || store.closing
|
||||
setStore({ dock: true, closing: false })
|
||||
if (hidden) {
|
||||
setStore("opening", true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setStore("opening", false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("opening", false)
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ dock: true, opening: false, closing: true })
|
||||
if (!timer) scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return {
|
||||
blocked,
|
||||
questionRequest,
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
decide,
|
||||
todos,
|
||||
dock: () =>
|
||||
store.sessionID === params.id
|
||||
? store.dock
|
||||
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
|
||||
closing: () => store.sessionID === params.id && store.closing,
|
||||
opening: () => store.sessionID === params.id && store.opening,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const doneToken = "\u0000done\u0000"
|
||||
const totalToken = "\u0000total\u0000"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="3"
|
||||
style={{
|
||||
animation: "var(--animate-pulse-scale)",
|
||||
"transform-origin": "center",
|
||||
"transform-box": "fill-box",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
height: 78,
|
||||
})
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
|
||||
const progress = createMemo(() =>
|
||||
language
|
||||
.t("session.todo.progress", { done: doneToken, total: totalToken })
|
||||
.split(/(\u0000done\u0000|\u0000total\u0000)/),
|
||||
)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
props.todos.find((todo) => todo.status === "in_progress") ??
|
||||
props.todos.find((todo) => todo.status === "pending") ??
|
||||
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
|
||||
props.todos[0],
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||
}
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
"white-space": "pre",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
}}
|
||||
>
|
||||
<Index each={progress()}>
|
||||
{(item) =>
|
||||
item() === doneToken ? (
|
||||
<AnimatedNumber value={done()} />
|
||||
) : item() === totalToken ? (
|
||||
<AnimatedNumber value={total()} />
|
||||
) : (
|
||||
<span>{item()}</span>
|
||||
)
|
||||
}
|
||||
</Index>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={props.collapsed ? preview() : undefined}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[] }) {
|
||||
const [store, setStore] = createStore({
|
||||
stuck: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||
}}
|
||||
>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
opacity: store.stuck ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import {
|
||||
SessionComposerRegion,
|
||||
createSessionComposerController,
|
||||
createSessionComposerRegionController,
|
||||
} from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
id: "components-todo-panel-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
This playground renders the real session composer region from app code.
|
||||
|
||||
### Source path
|
||||
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
|
||||
|
||||
### Includes
|
||||
- \`SessionTodoDock\` (real)
|
||||
- \`PromptInput\` (real)
|
||||
|
||||
No visual reimplementation layer is used for the dock/input stack.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
|
||||
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
|
||||
"Run typechecks/tests and report what changed",
|
||||
"Verify reduced-motion behavior in timeline",
|
||||
"Review diff for animation edge cases",
|
||||
"Document rollout notes in PR description",
|
||||
"Check keyboard and screen reader semantics",
|
||||
"Add storybook controls for iteration speed",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const controls = {
|
||||
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
|
||||
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: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-component="todo-preview"] {
|
||||
height: 560px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-component="todo-session-root"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
[data-component="todo-session-frame"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="todo-session-panel"] {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-content"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-scroll"] {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-spacer"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"] {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-weak);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"][data-strong="true"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
`
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const prompt = usePrompt()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
dockCloseDuration: 0.3,
|
||||
dockCloseBounce: 0,
|
||||
drawerExpandDuration: 0.3,
|
||||
drawerExpandBounce: 0,
|
||||
drawerCollapseDuration: 0.3,
|
||||
drawerCollapseBounce: 0,
|
||||
subtitleDuration: 600,
|
||||
subtitleAuto: true,
|
||||
subtitleTravel: 25,
|
||||
subtitleEdge: 17,
|
||||
countDuration: 600,
|
||||
countMask: 18,
|
||||
countMaskHeight: 0,
|
||||
countWidthDuration: 560,
|
||||
})
|
||||
const open = () => cfg.open
|
||||
const step = () => cfg.step
|
||||
const dockOpenDuration = () => cfg.dockOpenDuration
|
||||
const dockOpenBounce = () => cfg.dockOpenBounce
|
||||
const dockCloseDuration = () => cfg.dockCloseDuration
|
||||
const dockCloseBounce = () => cfg.dockCloseBounce
|
||||
const drawerExpandDuration = () => cfg.drawerExpandDuration
|
||||
const drawerExpandBounce = () => cfg.drawerExpandBounce
|
||||
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
|
||||
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
|
||||
const subtitleDuration = () => cfg.subtitleDuration
|
||||
const subtitleAuto = () => cfg.subtitleAuto
|
||||
const subtitleTravel = () => cfg.subtitleTravel
|
||||
const subtitleEdge = () => cfg.subtitleEdge
|
||||
const countDuration = () => cfg.countDuration
|
||||
const countMask = () => cfg.countMask
|
||||
const countMaskHeight = () => cfg.countMaskHeight
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
const done = Math.max(0, Math.min(3, step()))
|
||||
return pool.slice(0, 3).map((content, i) => ({
|
||||
id: `todo-${i + 1}`,
|
||||
content,
|
||||
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
if (!scrollRef) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setCfg("open", true)
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
frame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const closeDock = () => {
|
||||
clear()
|
||||
setCfg("open", false)
|
||||
}
|
||||
|
||||
const dockOpen = () => open()
|
||||
|
||||
const toggleDock = () => {
|
||||
if (dockOpen()) {
|
||||
closeDock()
|
||||
return
|
||||
}
|
||||
openDock()
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
if (!dockOpen()) {
|
||||
openDock()
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
setCollapsed(true)
|
||||
frame = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setCollapsed(!collapsed())
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
setCfg("step", (value) => (value + 1) % 4)
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return (
|
||||
<div data-component="todo-stage">
|
||||
<style>{css}</style>
|
||||
|
||||
<div data-component="todo-preview">
|
||||
<div data-component="todo-session-root">
|
||||
<div data-component="todo-session-frame">
|
||||
<div data-component="todo-session-panel">
|
||||
<div data-slot="todo-preview-content">
|
||||
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
|
||||
<div data-slot="todo-preview-spacer" />
|
||||
<div data-slot="todo-preview-msg" data-strong="true">
|
||||
Thinking Checking type safety
|
||||
</div>
|
||||
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: pin,
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={controls}
|
||||
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleDock} style={btn(dockOpen())}>
|
||||
{dockOpen() ? "Animate close" : "Animate open"}
|
||||
</button>
|
||||
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
|
||||
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
|
||||
</button>
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenDuration()}
|
||||
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockOpenDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenBounce()}
|
||||
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockOpenBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Dock close
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseDuration()}
|
||||
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockCloseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseBounce()}
|
||||
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockCloseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer expand
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandDuration()}
|
||||
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerExpandDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandBounce()}
|
||||
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerExpandBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer collapse
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseDuration()}
|
||||
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerCollapseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseBounce()}
|
||||
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerCollapseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Subtitle odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={subtitleDuration()}
|
||||
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(subtitleDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
auto fit
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleAuto()}
|
||||
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{subtitleAuto() ? "on" : "off"}
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
travel
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleTravel()}
|
||||
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
edge
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleEdge()}
|
||||
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Count odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={countDuration()}
|
||||
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="40"
|
||||
step="1"
|
||||
value={countMask()}
|
||||
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask height
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="14"
|
||||
step="1"
|
||||
value={countMaskHeight()}
|
||||
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
width spring
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={countWidthDuration()}
|
||||
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countWidthDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
@@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -56,9 +56,15 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (FileDiffInfo | VcsFileDiff)[]
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
@@ -104,7 +110,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffs = createMemo(() => props.diffs())
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
|
||||
@@ -1255,7 +1255,7 @@ export function MessageTimeline(props: {
|
||||
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
||||
if (part?.type === "tool") return part
|
||||
}
|
||||
const asyncFile = () => ["edit", "write", "patch", "apply_patch"].includes(tool()?.tool ?? "")
|
||||
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
|
||||
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
||||
let contentMeasureFrame: number | undefined
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = FileDiffInfo | VcsFileDiff
|
||||
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function reviewDiffNeedsLoad(diff: RenderDiff) {
|
||||
if (diff.additions === 0 && diff.deletions === 0) return false
|
||||
return !diff.patch || !/^@@ /m.test(diff.patch)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
@@ -21,6 +21,7 @@ import FileTreeV2 from "@/components/file-tree-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import {
|
||||
filterRenderableDiff,
|
||||
filterReviewFiles,
|
||||
reviewDiffKinds,
|
||||
reviewDiffNeedsLoad,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
@@ -55,7 +56,7 @@ export type ReviewPanelV2Props = {
|
||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const sdk = useSDK()
|
||||
|
||||
const diffs = createMemo(() => props.diffs())
|
||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
||||
const filteredFiles = createMemo(() =>
|
||||
filterReviewFiles(
|
||||
diffs().map((diff) => diff.file),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { diffs, message } from "./diffs"
|
||||
|
||||
@@ -9,7 +9,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies FileDiffInfo
|
||||
} satisfies SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user