Compare commits

...

87 Commits

Author SHA1 Message Date
opencode-agent[bot] 1f83e8fe21 Fix beta integration 2026-08-15 03:20:18 +00:00
Kit Langton c42c7f7793 docs(tui): remove completed extraction plan 2026-08-14 23:18:46 -04:00
Kit Langton ab7a0bf65c fix(tui): ignore stray releases on new session controls (#42673) 2026-08-14 20:57:49 -04:00
Kit Langton 552fd40ef8 docs: update contributing guide 2026-08-14 20:54:35 -04:00
Kit Langton 8afcb3870e fix(plugin): derive promise adapter from protocol schemas (#42669) 2026-08-15 00:52:06 +00:00
Kit Langton 014a364dfd docs(core): refresh session architecture 2026-08-14 20:32:06 -04:00
opencode-agent[bot] a45b12cfa4 fix(app): use location VCS state (#42666) 2026-08-15 10:30:46 +10:00
Kit Langton 7484e32f62 refactor(protocol): harden simulation wire contract (#42628) 2026-08-14 20:29:53 -04:00
opencode-agent[bot] 650f7e8cdd chore: generate 2026-08-15 00:20:16 +00:00
Dax 5c7f5840ee feat(core): persist web search provider selection (#42663) 2026-08-14 20:19:02 -04:00
opencode-agent[bot] 08a6d7b619 docs: fix package manager code blocks (#42313)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-14 20:03:48 -04:00
opencode-agent[bot] 1580e7cc3a chore: generate 2026-08-14 23:41:12 +00:00
Aiden Cline 8640ea3374 minimize system prompt (#42638) 2026-08-14 18:39:27 -05:00
opencode-agent[bot] e6a3b951b5 chore: generate 2026-08-14 22:19:55 +00:00
James Long 62b67f2761 refactor(protocol): move worktree routes out of experimental namespace (#42656) 2026-08-14 18:18:40 -04:00
James Long 182fe17f12 fix(tui): remove shadow scrim under horizontal tab strip (#42650) 2026-08-14 16:06:32 -04:00
opencode-agent[bot] b6ea0c2209 chore: generate 2026-08-14 19:44:43 +00:00
Kit Langton f7b222e95a fix(tui): open footer status dialogs on click 2026-08-14 15:43:07 -04:00
Kit Langton a7288d231e feat(tui): add working directory actions (#42624) 2026-08-14 15:29:22 -04:00
Kit Langton f75244d795 fix(tui): preserve tab drag source (#42619) 2026-08-14 17:59:18 +00:00
Kit Langton 1d44d56d9c fix(tui): use semantic form tokens (#42599) 2026-08-14 17:49:06 +00:00
Kit Langton 9adc9bb5df fix(tui): preserve full-width tab hover 2026-08-14 13:41:39 -04:00
Kit Langton a609acbb67 fix(tui): refine tab marquee motion (#42617) 2026-08-14 13:30:21 -04:00
Kit Langton f4620939aa test(server): remove workerd spike (#42618) 2026-08-14 13:21:26 -04:00
Kit Langton 23258ada86 feat(tui): resize vertical tab rail (#42615) 2026-08-14 13:16:49 -04:00
opencode-agent[bot] 2ca6d8ee49 chore: generate 2026-08-14 17:10:13 +00:00
opencode-agent[bot] d6c299cee7 fix(ai): remove over-safe provider error handling (#42610)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-14 12:08:59 -05:00
Kit Langton 737b6eb7b7 chore: extract Drive and catalog (#42612) 2026-08-14 13:05:24 -04:00
opencode-agent[bot] 923cb28a0a chore: generate 2026-08-14 16:39:28 +00:00
Kit Langton e79f3e8ca2 feat(tui): animate interrupt confirmation (#42602) 2026-08-14 16:38:06 +00:00
Shoubhit Dash 638cb414c4 test(ai): harden websocket lifecycle coverage (#42600) 2026-08-14 22:07:06 +05:30
Shoubhit Dash 445aacf92e feat(ai): add Responses websocket continuation (#42596) 2026-08-14 22:06:29 +05:30
Shoubhit Dash a2a53032d4 feat(core): manage session websocket channels (#41564) 2026-08-14 22:05:43 +05:30
Shoubhit Dash 810156d081 refactor(core): expose session transport eligibility (#41558) 2026-08-14 22:05:02 +05:30
Shoubhit Dash c0ae362996 refactor(ai): unify OpenAI Responses transport (#41048) 2026-08-14 22:04:16 +05:30
Shoubhit Dash de32afe358 refactor(ai): add sequential websocket channel seam (#41045) 2026-08-14 22:03:31 +05:30
Shoubhit Dash 2d953a69de fix(ai): harden websocket error contracts (#40695) 2026-08-14 22:01:37 +05:30
Kit Langton e3bbfdf270 feat(simulation): slim production bridge (#42584) 2026-08-14 15:57:44 +00:00
Shoubhit Dash 6d0c30a125 fix(tui): defer background work hint (#41563) 2026-08-14 20:57:36 +05:30
Kit Langton 1cf97fac5c fix(tui): optimize Mermaid terminal rendering (#42595) 2026-08-14 11:10:32 -04:00
opencode-agent[bot] f7d21babb6 chore: generate 2026-08-14 15:07:36 +00:00
Kit Langton b0480a6f93 feat(core): make portable shell scanner authoritative (#42581) 2026-08-14 11:06:04 -04:00
Kit Langton bdbccca1f3 feat(tui): refine question form interactions (#42493) 2026-08-14 14:54:32 +00:00
Shoubhit Dash 3d3bbd48f9 feat(core): add tavily web search (#42591) 2026-08-14 20:20:10 +05:30
opencode-agent[bot] 51f0b499f9 chore: generate 2026-08-14 14:40:07 +00:00
opencode-agent[bot] 9872fb8a54 refactor(api): remove legacy question service (#42562)
Co-authored-by: Filip Hejmowski <fhejmowski@simplito.com>
Co-authored-by: neriousy <hejmowskifilip@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 14:38:32 +00:00
Kit Langton baf5ce3b7e fix(tui): debounce shared storage reloads (#42579) 2026-08-14 14:14:42 +00:00
Kit Langton 70efd3fb38 fix(tui): preserve background hint timing (#42577) 2026-08-14 13:18:53 +00:00
Kit Langton a1a663d60e refactor(cli): replace updater semver dependency (#42492) 2026-08-14 09:09:52 -04:00
Kit Langton 4a899bb730 refactor(tui): rebuild tab pulse on gated envelopes (#42496) 2026-08-14 08:54:16 -04:00
opencode-agent[bot] b6b313943f chore: generate 2026-08-14 12:41:52 +00:00
opencode-agent[bot] 3a4c8cdf9d revert(core): remove portable shell permission scanner (#42571)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 08:40:40 -04:00
opencode-agent[bot] 8d71099577 chore: generate 2026-08-14 12:23:07 +00:00
Simon Klee c0b4a0ce91 tui: update OpenTUI to 0.5.3 (#42568) 2026-08-14 14:21:52 +02:00
Luke Parker d698746951 fix(app): stabilize session navigation (#42477) 2026-08-14 14:37:56 +10:00
Luke Parker 72d37cda81 feat(desktop): show migration progress (#42495) 2026-08-14 13:40:18 +10:00
Brendan Allan ae14e78ba6 refactor(app): consolidate desktop server data contexts (#42256) 2026-08-14 11:25:08 +08:00
Kit Langton 9ad482aab9 feat(tui): diffuse unread tab glow on resolve (#42487) 2026-08-13 23:24:37 -04:00
Kit Langton fec4f20736 perf(core): load MCP client lazily (#42468) 2026-08-13 22:48:09 -04:00
opencode-agent[bot] 49d07ffe5f fix(core): use file times for tool output cleanup (#42450)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 21:44:16 -05:00
opencode-agent[bot] 28f6968dda fix(www): point edit links to v2 (#42472)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 21:42:46 -05:00
opencode-agent[bot] 979ac810af feat(app): add Hebrew locale (#42475)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-14 12:31:48 +10:00
Kit Langton 71caa7d406 fix(tui): isolate tab scroll state (#42456) 2026-08-13 22:04:40 -04:00
Kit Langton 82b96eb495 fix(tui): scope unread updates to focused terminal (#42471) 2026-08-13 21:54:56 -04:00
Kit Langton 7909817fba chore(app): remove stale frontend dependencies (#42464) 2026-08-13 21:52:59 -04:00
Kit Langton 23b7632743 refactor(core): remove bus replay all (#42460) 2026-08-13 21:52:48 -04:00
Kit Langton de28ca9f0e refactor(util): load npm package parser lazily (#42467) 2026-08-13 21:48:48 -04:00
Kit Langton 9d9e57941c chore(ui): remove stale motion pins (#42465) 2026-08-13 21:48:30 -04:00
Kit Langton 0e28d651a5 refactor(core): remove unused API members (#42463) 2026-08-13 21:48:12 -04:00
Kit Langton a5b6ead43e perf(util): load npm config lazily (#42458) 2026-08-13 21:19:32 -04:00
Kit Langton 7a73aad0a2 chore: remove orphaned v2 exports (#42459) 2026-08-13 21:18:58 -04:00
Kit Langton 304c85fb2b refactor(util): remove xdg-basedir dependency (#42462) 2026-08-13 21:18:48 -04:00
Kit Langton 85988b957b refactor(core): trim sqlite adapter paths (#42457) 2026-08-13 21:18:05 -04:00
Kit Langton 146aeef830 refactor(util): remove unused npm install (#42454) 2026-08-13 21:17:52 -04:00
Kit Langton 28b630477d fix(tui): recover sessions from missing locations (#42455) 2026-08-13 20:49:18 -04:00
Kit Langton 5cd7705036 fix(tui): correct tab context menu behavior (#42453) 2026-08-13 20:39:26 -04:00
Kit Langton 5816fcbc61 fix(tui): preserve toast hover state (#42419) 2026-08-13 20:31:37 -04:00
Luke Parker 655b3c4c22 feat(app): add workspace flows to new layout (#38790) 2026-08-14 10:19:45 +10:00
Dax 8023ba378b fix(cli): defer update check until service resolves (#42446) 2026-08-14 00:13:33 +00:00
opencode-agent[bot] 1bd1eb0e0b chore: generate 2026-08-13 22:08:50 +00:00
James Long ad8ed9b367 refactor(core): replace project copies with worktrees (#42149) 2026-08-13 18:06:28 -04:00
opencode-agent[bot] 97a6bc19e7 chore: generate 2026-08-13 21:55:40 +00:00
Kit Langton e8101ef7fe feat(core): add portable shell permission scanner (#42351) 2026-08-13 17:53:35 -04:00
opencode-agent[bot] 8f3e86cf4e fix(app): support bidi text input (#42413)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-14 07:48:58 +10:00
opencode-agent[bot] d9700c4cd5 chore: generate 2026-08-13 21:40:18 +00:00
Kit Langton 95be07463b feat(tui): add interactive toast actions (#42407) 2026-08-13 21:38:12 +00:00
Dax Raad 40cd6989d2 fix(core): update console server URL 2026-08-13 17:04:46 -04:00
906 changed files with 17607 additions and 162025 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }} run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+48
View File
@@ -0,0 +1,48 @@
# he Glossary
## Sources
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose and UI copy)
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
- Keep `commit` and `diff` when they name the exact Git artifact or operation
## Preferred Terms
| English / Context | Preferred | Notes |
| ----------------- | ------------- | ------------------------------------------------------------------------- |
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
| workspace | `סביבת עבודה` | |
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
| command | `פקודה` | |
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
| model | `מודל` | |
| API key | `מפתח API` | Keep the acronym in Latin letters |
| plugin | `תוסף` | |
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
| branch | `ענף` | |
| context | `הקשר` | Use `חלון הקשר` for context window |
| tokens | `אסימונים` | |
## Guidance
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
- Use short action verbs for controls and translate complete phrases in context.
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
## Avoid
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
- Avoid translating `commit` as `התחייבות`.
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
-253
View File
@@ -1,253 +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/anomalyco/opencode/v2/packages/drive/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/anomalyco/opencode/v2/packages/drive/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/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/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
```
+21 -7
View File
@@ -12,6 +12,20 @@
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. - The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. - Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
## TUI Theme Tokens
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
## Branch Names ## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
@@ -161,14 +175,14 @@ const table = sqliteTable("session", {
## V2 Session Core ## 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 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. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` 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. - Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- 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 `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - 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 Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. 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 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. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- 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. - Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting 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. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep event replay ownership 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. - 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 retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta. - `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
+63 -223
View File
@@ -1,272 +1,112 @@
# Contributing to OpenCode # Contributing to OpenCode
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged: The changes most likely to be accepted are:
- Bug fixes - Bug fixes
- Additional LSPs / Formatters - Additional LSPs and formatters
- Improvements to LLM performance - LLM performance improvements
- Support for new providers - Environment-specific fixes
- Fixes for environment-specific quirks
- Missing standard behavior - Missing standard behavior
- Documentation improvements - Documentation improvements
However, any UI or core product feature must go through a design review with the core team before implementation. UI and core product features require design review before implementation. If you are unsure whether a change fits, ask a maintainer or choose an issue labeled [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted), [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22), [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug), or [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22).
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels: Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE] > [!NOTE]
> PRs that ignore these guardrails will likely be closed. > PRs that ignore these guardrails will likely be closed.
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. ## Adding Providers
## Adding New Providers New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to: ## Development
https://github.com/anomalyco/models.dev
## Developing OpenCode OpenCode requires Bun 1.3 or newer. From the repository root:
- Requirements: Bun 1.3+
- Install dependencies and start the dev server from the repo root:
```bash
bun install
bun dev
```
### Running against a different directory
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
```bash ```bash
bun dev <directory> bun install
bun dev [directory]
``` ```
To run OpenCode in the root of the opencode repo itself: `bun dev` runs the V2 CLI and TUI. Pass a directory to open another project, or `.` to open this repository.
To test a development TUI against your installed OpenCode V2 background service and live sessions:
```bash ```bash
bun dev . bun run dev:live [directory]
``` ```
### Building a "localcode" For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
To compile a standalone executable:
```bash ```bash
./packages/opencode/script/build.ts --single bun dev serve --port 4096
bun run dev:web
bun run dev:desktop
bun run dev:www
``` ```
Then run it with: ### Packages
- `packages/schema`: shared wire and storage contracts
- `packages/core`: domain behavior and persistence
- `packages/protocol`: public API definitions
- `packages/server`: HTTP server and runtime composition
- `packages/client`: generated TypeScript clients
- `packages/cli`: command-line entrypoint and service lifecycle
- `packages/tui`: terminal interface
- `packages/app`: shared web interface
- `packages/desktop`: Electron desktop application
- `packages/plugin`: plugin API
### Verification
Run typechecks, and tests where defined, from the affected package rather than the repository root:
```bash ```bash
./packages/opencode/dist/opencode-<platform>/bin/opencode cd packages/core
bun run test
bun typecheck
``` ```
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`). Follow package-specific instructions in nearby `AGENTS.md` files. After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`; never edit generated client files directly.
- Core pieces: Follow the repository [style guide](./AGENTS.md).
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
### Understanding bun dev vs opencode ## Pull Requests
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface: ### Link Issues When Required
```bash Bug fixes, chores, and tests must reference an existing issue. Documentation, refactor, and feature PRs are exempt from the automated linked-issue check. When required, use `Fixes #123` or `Closes #123` in the PR description.
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
bun dev <directory> # Start TUI in specific directory
# Production Before implementing new functionality, open a feature request describing the problem, why it belongs in OpenCode, and your proposed approach if you have one. Wait for design approval before opening the implementation PR.
opencode --help # Show all available commands
opencode serve # Start headless API server
opencode web # Start server + open web interface
opencode <directory> # Start TUI in specific directory
```
### Running the API Server Base branches on `v2`, not `dev`, and complete the provided pull request template.
To start the OpenCode headless API server: ### Keep It Focused
```bash - Keep PRs small and focused.
bun dev serve - Explain the problem and why the change fixes it.
``` - Check whether the functionality already exists.
- For UI changes, include before-and-after screenshots or video.
- For logic changes, explain what you tested and how a reviewer can verify it.
This starts the headless server on port 4096 by default. You can specify a different port: ### Keep It Brief
```bash Long, AI-generated PR descriptions and issues may be ignored. Write a short explanation in your own words. If the change cannot be explained briefly, the PR may be too large.
bun dev serve --port 8080
```
### Running the Web App ### Use Conventional Titles
To test UI changes during development: Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above)
2. **Then run the web app:**
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Running the Desktop App
The desktop app is an Electron application that wraps the web UI.
To run the desktop app in development:
```bash
bun run --cwd packages/desktop dev
```
To create a production build and package the app:
```bash
bun run --cwd packages/desktop build
bun run --cwd packages/desktop package
```
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./AGENTS.md)
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples: Examples:
- `docs: update contributing guidelines` - `docs: update contributing guide`
- `fix: resolve crash on startup` - `fix(tui): restore scroll position`
- `feat: add dark mode support` - `feat(app): add workspace search`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
### Style Preferences ## Issues
These are not strictly enforced, they are just general guidelines: Bug reports and feature requests must use their issue templates. Blank issues are not allowed; ask support and how-to questions in the [Discord community](https://discord.gg/opencode).
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits. Automated checks flag missing templates, placeholder text, AI-generated walls of text, and missing meaningful content. You have two hours to correct a flagged issue before it closes automatically. Ask a maintainer if an issue was flagged incorrectly.
- **Destructuring:** Do not do unnecessary destructuring of variables.
- **Control flow:** Avoid `else` statements.
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
- **Types:** Reach for precise types and avoid `any`.
- **Variables:** Stick to immutable patterns and avoid `let`.
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Issue Requirements
All issues **must** use one of our issue templates:
- **Bug report** — for reporting bugs (requires a description)
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
- **Question** — for asking questions (requires the question)
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
Issues may be flagged for:
- Not using a template
- Required fields left empty or filled with placeholder text
- AI-generated walls of text
- Missing meaningful content
If you believe your issue was incorrectly flagged, let a maintainer know.
+69 -738
View File
File diff suppressed because it is too large Load Diff
+34 -29
View File
@@ -27,8 +27,9 @@ exits before expensive server boot. The design does not require clients to
agree on a single initiator. agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server, This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery protocol negotiation, idle background restart, or clustered or exactly-once
framework. execution recovery. Session execution separately provides bounded local recovery
through durable write-ahead claims.
## Architecture at a Glance ## Architecture at a Glance
@@ -176,9 +177,9 @@ This design gives each concept one authority.
- Adding a permanent steward, proxy, or supervisor process. - Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback. - Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart. - Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions. - Exactly-once recovery for provider attempts, tools, shells, sub-agents,
- Defining recovery semantics for provider attempts, tools, shells, sub-agents, permissions, questions, or background jobs. Top-level Session continuation
permissions, questions, or background jobs. after process death is handled separately through durable execution claims.
- Automatically killing a frozen owner. - Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect. - Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement. - Multi-machine or clustered service placement.
@@ -202,9 +203,10 @@ This design gives each concept one authority.
diagnosed, non-retryable cause. diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive 8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command. recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement 9. **Lifecycle does not promise exactly-once execution.** A successor invokes
invokes Session suspension and resumption hooks, but tool-level continuity the Session execution-claim sweep, which resumes from durable history.
belongs to a separate design. Provider-attempt identity and tool-side-effect fencing belong to separate
designs.
## System Model ## System Model
@@ -485,23 +487,20 @@ The UI derives text from status:
| `ready` | Normal TUI | | `ready` | Normal TUI |
## Session Continuity ## Session Continuity
Every process-local Session busy period writes a durable execution claim before Every process-local Session busy period writes a durable execution claim before
resumption hooks: its runner starts. Success, failure, and user interruption release the claim;
shutdown interruption and process death leave it intact. The
successor sweeps claimed top-level Sessions, durably counts a recovery attempt,
appends a continuation instruction, and resumes from projected history. The same
mechanism covers graceful replacement, crash, SIGKILL, and runtime eviction.
Recovery fails stale running tool projections before further model work, but it Recovery fails stale running tool projections before further model work, but it
2. The successor schedules those Sessions for continuation. does not prove whether an interrupted provider request or external operation
3. The runner reloads durable Session history before continuing. already took effect. It does not replay the exact interrupted tool, preserve an
in-memory form, recover process-local background work, or guarantee exactly-once
This lifecycle design does not define what an interrupted physical provider provider or tool behavior.
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
## Unresponsive Owner ## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender An unreachable registration does not prove that the owner is dead. A contender
@@ -531,13 +530,15 @@ Automatic frozen-owner recovery is deferred.
1. The old service installs vNext but keeps running. 1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop. 2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping` and exits. Shutdown interruption preserves 3. The old service reports `stopping` and exits. Shutdown interruption preserves
4. Open TUIs enter their indefinite status loops. the execution claims already written by active Sessions.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders. 5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot. 6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`. 7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner. 8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application, sweeps orphaned execution claims, 9. The winner initializes the application, sweeps orphaned execution claims,
10. TUIs rebuild clients, reconcile state, and resume. and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready ### Server crashes while ready
@@ -546,7 +547,9 @@ Automatic frozen-owner recovery is deferred.
3. Process death has released the service lock. 3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally. 4. One contender wins, replaces registration, and starts normally.
5. Application startup sweeps orphaned top-level execution claims and resumes 5. Application startup sweeps orphaned top-level execution claims and resumes
them with bounded attempt accounting. External side effects remain
potentially ambiguous.
### Winner crashes during startup ### Winner crashes during startup
1. Clients observed `starting` and remain alive. 1. Clients observed `starting` and remain alive.
@@ -650,8 +653,9 @@ was the observed incident cost.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version; 6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement. reconnect never activates replacement.
7. **Integrate Session continuity.** Preserve current background-install and 7. **Integrate Session continuity.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks. fresh-launch activation behavior while invoking startup execution-claim
8. **Harden explicit recovery.** Verify exact process identity during explicit recovery.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner. `service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and 9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward. assert that no contender or child process remains afterward.
@@ -677,7 +681,8 @@ was the observed incident cost.
- Idle background update activation with an admission fence. - Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec. - Application protocol compatibility and automatic local TUI re-exec.
- Stronger execution recovery with provider-attempt identity, tool-side-effect - Stronger execution recovery with provider-attempt identity, tool-side-effect
- Shell, sub-agent, permission, question, and background-job continuity. idempotency or fencing, and clustered ownership.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner. - Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading. - Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes - A steward or socket-handoff architecture if zero-downtime replacement becomes
+3 -5
View File
@@ -33,7 +33,6 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2", "@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.2", "@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.2", "@opentui/solid": "0.5.3",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -174,7 +173,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
} }
} }
+8 -7
View File
@@ -80,7 +80,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport. When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
### URL Construction ### URL Construction
@@ -106,7 +106,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
Keep provider facades small and explicit: Keep provider facades small and explicit:
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly. - Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`. - Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` and `chat`.
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path. - Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them. - Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`. - Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
@@ -124,11 +124,10 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey, apiKey,
transport: "websocket",
}) })
``` ```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`. Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Transport is execution policy: OpenAI Responses uses HTTP by default and may receive a per-call WebSocket channel executor through `StreamOptions` without changing model or route identity.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -154,14 +153,16 @@ packages/ai/src/
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse framing.ts Framing type + Framing.sse
transport/ transport implementations transport/ transport implementations
index.ts Transport type + HttpTransport / WebSocketTransport namespaces index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing http.ts HttpTransport.httpJson — POST + framing
websocket.ts WebSocketTransport.json + WebSocketExecutor service websocket.ts direct one-request channel executor + raw socket adapter
protocols/ protocols/
shared.ts ProviderShared toolkit used inside protocol impls shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol) openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline open-responses.ts provider-neutral Responses protocol baseline
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts anthropic-messages.ts
gemini.ts gemini.ts
bedrock-converse.ts bedrock-converse.ts
+1 -2
View File
@@ -315,7 +315,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
transport: "websocket",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 }, limits: { context: 200_000, output: 64_000 },
}) })
@@ -332,7 +331,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/google-vertex/responses` - `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages` - `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
+33 -34
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status # LLM Provider Parity Status
Last reviewed: 2026-07-24 Last reviewed: 2026-08-07
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -16,8 +16,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| Native slice | Source | Current state | Main gaps | | Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | | OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | | OpenAI Responses | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable over HTTP by default, with optional per-call WebSocket channel execution on the same model and route identity. | No incremental `previous_response_id` path or persistent Session channel manager yet. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | | OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. | | Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. | | Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
@@ -48,19 +47,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## AI SDK Package Parity Matrix ## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps | | AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. | | `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. | | `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | | `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. | | `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. | | `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. | | `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. | | `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. | | `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps ## Highest-Risk Gaps
@@ -78,24 +77,24 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
These are implementation/API slices, not separate npm packages. These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose | | API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | | Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | | Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | | Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | | Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | | Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | | Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | | Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | | Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. | | Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. | | Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | | Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | | Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices ## Suggested Next Work Slices
+18 -41
View File
@@ -67,7 +67,6 @@ Examples:
```ts ```ts
OpenAI.responses("gpt-4o") OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o") OpenAI.chat("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment") Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0") AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
@@ -250,11 +249,6 @@ const openAIChat = Route.make({
auth: Auth.envBearer("OPENAI_API_KEY"), auth: Auth.envBearer("OPENAI_API_KEY"),
}) })
const openAIResponsesWebSocket = openAIResponses.with({
id: "openai-responses-websocket",
transport: WebSocketTransport.json,
})
const openAIConfig = (input: OpenAIConfig) => ({ const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint, endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
@@ -266,13 +260,11 @@ const openAIConfig = (input: OpenAIConfig) => ({
const configureOpenAI = (input: OpenAIConfig = {}) => { const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input)) const responses = openAIResponses.with(openAIConfig(input))
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input)) const chat = openAIChat.with(openAIConfig(input))
return { return {
id: openAIProvider, id: openAIProvider,
responses: responses.model, responses: responses.model,
responsesWebSocket: responsesWebSocket.model,
chat: chat.model, chat: chat.model,
model: responses.model, model: responses.model,
configure: configureOpenAI, configure: configureOpenAI,
@@ -342,22 +334,19 @@ const response =
) )
``` ```
For direct provider-facade calls, HTTP versus WebSocket is represented as named For direct provider-facade calls, Responses has one semantic model and route:
route selectors, not as model or request overrides. Same protocol, different
transport, different route:
```ts ```ts
OpenAI.responses("gpt-4o") OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
``` ```
The package-like OpenAI Responses entrypoint instead keeps transport scoped to The package-like OpenAI Responses entrypoint has the same transport-neutral
Responses settings while preserving the same `model(...)` contract: `model(...)` contract:
```ts ```ts
import { model } from "@opencode-ai/ai/providers/openai/responses" import { model } from "@opencode-ai/ai/providers/openai/responses"
model("gpt-4o", { apiKey, transport: "websocket" }) model("gpt-4o", { apiKey })
``` ```
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints, Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
@@ -387,11 +376,9 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project, location: "global" }) model("claude-sonnet-4-6", { project, location: "global" })
``` ```
The client should not require a different public layer just because a selected The client does not require a different public layer for WebSocket execution.
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime Responses routes use HTTP by default, and callers may pass a channel executor per
capabilities available; routes that do not need WebSocket simply never touch it. call. Routes without channel support simply ignore that execution capability.
If a WebSocket route is selected in an environment without WebSocket support,
fail with a typed transport configuration error.
Azure is a route specialization with auth/path/default changes plus input Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects mapping. The public API configures the Azure resource once, then selects
@@ -497,18 +484,13 @@ generic dynamic resolver:
```ts ```ts
const model = const model =
providerID === "azure" providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
: endpoint.websocket
? OpenAI.responsesWebSocket(apiModelID)
: OpenAI.responses(apiModelID)
``` ```
That boundary can branch on durable config/catalog metadata and call typed That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. A direct provider-facade boundary maps metadata like provider APIs directly. Transport selection remains execution policy: a Session
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading or other caller may pass a WebSocket channel executor per call without changing
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint. the model constructed by this boundary.
The client runtime only executes the route carried by the resulting model.
## Competitive Shape ## Competitive Shape
@@ -544,9 +526,8 @@ App boundary = explicit durable-config -> typed-provider call
id. id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id; - No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first. endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on an executable model or request. Direct provider - No transport setting on a provider or executable model. OpenAI Responses uses
facades use `responses` versus `responsesWebSocket`; the package-like Responses HTTP by default and accepts an optional per-call channel executor as execution policy.
entrypoint maps its scoped `transport` setting before constructing the model.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities. client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model - No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
@@ -580,12 +561,10 @@ App boundary = explicit durable-config -> typed-provider call
- [x] Make unconfigured transports reusable constants such as - [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh `HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction. state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` - [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
exposes available transport capabilities and selected routes fail with typed optional per-call channel execution without changing route identity.
transport config errors when a required capability is missing.
- [x] Convert OpenAI provider APIs to provider-facade shape: - [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and `OpenAI.configure(config).responses(id)` and `.chat(id)`.
`.responsesWebSocket(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version - [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids. setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as - [x] Split Cloudflare products into separate facades such as
@@ -599,10 +578,8 @@ App boundary = explicit durable-config -> typed-provider call
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two - [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not or three provider conversions; start with plain objects if duplication is not
yet painful. yet painful.
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct - [x] Keep executable model construction transport-neutral at the Session boundary;
executable models at the session boundary with explicit provider facade Session-scoped execution policy supplies channel capability separately.
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
named route selector.
- [ ] Update tests so direct route/provider tests assert route values are carried - [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route by executable models, and opencode/native tests assert boundary-based route
selection. selection.
+3 -4
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai" import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers" import { OpenAI } from "@opencode-ai/ai/providers"
/** /**
@@ -213,8 +213,7 @@ const FakeEcho = {
// enabled at a time so the tutorial can demonstrate generate, stream, or // enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example. // tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
@@ -222,6 +221,6 @@ const program = Effect.gen(function* () {
// yield* generateStructuredObject // yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
yield* streamWithTools yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer))) }).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
Effect.runPromise(program) Effect.runPromise(program)
+1
View File
@@ -7,3 +7,4 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js" export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js" export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js" export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js"
@@ -0,0 +1,191 @@
import { Effect, Schema, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Framing } from "../route/framing.js"
import {
HttpTransport,
WebSocketTransport,
type Transport,
type WebSocketChannelDriver,
type WebSocketChannelExchange,
} from "../route/transport/index.js"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown),
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
}
export interface Prepared {
readonly http: HttpTransport.HttpPrepared<string>
readonly channel?: {
readonly url: string
readonly headers: Headers.Headers
readonly rotateAfterMs?: number
readonly driver: WebSocketChannelDriver
}
}
const message = (body: unknown) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("Open Responses WebSocket body must be a JSON object")
const { stream: _stream, stream_options: _streamOptions, background: _background, ...request } = body
const decoded = yield* decodeMessage({ ...request, type: "response.create" })
return { request: decoded, message: encodeMessage(decoded) }
})
const driver = (options: Options, body: string): WebSocketChannelDriver => {
let responseID: string | undefined
let terminal = false
return {
create: () =>
Effect.sync(() => {
responseID = undefined
terminal = false
return { message: body, mode: "full" }
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
),
)
if (terminal)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} after a terminal event`,
frame,
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
}
}
if (event.type === "response.failed") {
terminal = true
if (responseID && event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
}
}
if (event.type === "response.created") {
const created = event.response?.id
if (responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted duplicate response.created`,
frame,
)
if (!created)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response.created is missing response.id`,
frame,
)
responseID = created
return { type: "frame", frame }
}
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} before response.created`,
frame,
)
if (event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
if (event.type === "response.completed") {
terminal = true
return { type: "completed", frame }
}
if (event.type === "response.incomplete") {
terminal = true
return { type: "incomplete", frame }
}
return { type: "frame", frame }
}),
}
}
export const transport = <Body>(options: Options): Transport<Body, Prepared, string> => {
const http = HttpTransport.sseJson.with<Body>()
return {
id: http.id,
prepare: (input) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
framing: Framing.sse,
middleware: input.middleware,
},
channel,
}
}),
execute: (prepared, request, runtime, executeOptions) => {
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: {
url: prepared.channel.url,
headers: prepared.channel.headers,
rotateAfterMs: prepared.channel.rotateAfterMs,
},
fallback: () =>
Stream.unwrap(
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
),
driver: prepared.channel.driver,
}
return executeOptions.webSocket.execute(exchange)
},
}
}
export const OpenResponsesChannel = { transport } as const
+54 -7
View File
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
// event-level `error` envelope, so accept all three shapes here. // event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification // https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({ const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String), code: optionalNull(Schema.String),
message: optionalNull(Schema.String), message: optionalNull(Schema.String),
param: optionalNull(Schema.String), param: optionalNull(Schema.String),
}) })
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest( export const Event = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
type: Schema.String, type: Schema.String,
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
message: Schema.optional(Schema.String), message: Schema.optional(Schema.String),
param: optionalNull(Schema.String), param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload), error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
) )
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = [] const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a // `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure. All three end the stream, // `finish` event; `response.failed` and `error` are hard failures. All four end
// so keep this set aligned with `step` and the protocol's terminal predicate. // the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type) export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
@@ -966,16 +1001,24 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
return message || code || fallback return message || code || fallback
} }
const providerError = (state: ParserState, event: Event, fallback: string) => { export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback) const message = providerErrorMessage(event, fallback)
const status =
typeof event.status === "number"
? event.status
: typeof event.status_code === "number"
? event.status_code
: undefined
return new AIError({ return new AIError({
module: state.id, module: id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code }), reason: classifyProviderFailure({ message, code, status }),
}) })
} }
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => { export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") { if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1015,7 +1058,11 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.completed" || event.type === "response.incomplete") if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event)) return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`) if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`) if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS]) return Effect.succeed<StepResult>([state, NO_EVENTS])
} }
@@ -0,0 +1,164 @@
import { AIError, TransportReason } from "../schema/index.js"
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "openai-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
readonly responseID: string
readonly request: Readonly<Record<string, unknown>>
readonly output: ReadonlyArray<unknown>
}
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
if (checkpoint?.protocol !== PROTOCOL || !ProviderShared.isRecord(checkpoint.value)) return undefined
if (checkpoint.value.version !== VERSION) return undefined
if (typeof checkpoint.value.responseID !== "string" || checkpoint.value.responseID.trim().length === 0)
return undefined
if (!ProviderShared.isRecord(checkpoint.value.request) || !Array.isArray(checkpoint.value.output)) return undefined
return {
version: VERSION,
responseID: checkpoint.value.responseID,
request: checkpoint.value.request,
output: checkpoint.value.output,
}
}
const canonical = (value: unknown): string => {
if (value === undefined) return "undefined"
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
}
const json = (value: unknown) => {
if (typeof value !== "string") return value
return Option.getOrElse(Schema.decodeUnknownOption(ProviderShared.Json)(value), () => value)
}
const comparable = (value: unknown) => {
if (!ProviderShared.isRecord(value)) return value
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
return {
type: value.type,
call_id: value.call_id,
name: value.name,
arguments: json(value.arguments),
}
if (value.type === "reasoning")
return {
type: value.type,
summary: value.summary,
encrypted_content: value.encrypted_content,
}
return value
}
const invariant = (request: Readonly<Record<string, unknown>>) => {
const { type: _type, input: _input, previous_response_id: _previousResponseID, ...rest } = request
return rest
}
const incremental = (
request: Readonly<Record<string, unknown>>,
checkpoint: CheckpointValue,
): ReadonlyArray<unknown> | undefined => {
const input = request.input
const previousInput = checkpoint.request.input
if (!Array.isArray(input) || !Array.isArray(previousInput)) return undefined
if (canonical(invariant(request)) !== canonical(invariant(checkpoint.request))) return undefined
const baseline = [...previousInput, ...checkpoint.output]
if (input.length <= baseline.length) return undefined
if (!baseline.every((item, index) => canonical(comparable(item)) === canonical(comparable(input[index]))))
return undefined
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
input: DriverInput,
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
recovery: "retry-full" | "rotate-and-retry-full",
): ChannelObservation => ({
type: "rejected",
recovery,
error: new AIError({
module: input.id,
method: "stream",
reason: new TransportReason({
message: observation.error.message,
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "rejected",
recovery,
}),
}),
})
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
)
const observation = yield* input.base.observe(create, frame)
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
if (observation.type === "provider-failure") {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
if (rejection === "websocket_connection_limit_reached")
return rejected(input, observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
},
}
}),
}
}
export const OpenAIResponsesChannel = { driver } as const
+14 -41
View File
@@ -1,18 +1,23 @@
import { Effect, Encoding, Schema } from "effect" import { Effect, Encoding, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Route } from "../route/client.js" import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { HttpTransport, WebSocketTransport } from "../route/transport/index.js" import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js" import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js" import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js" import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js" import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js" import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses" const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses" const NAME = "OpenAI Responses"
const WEBSOCKET_PROTOCOL_HEADER = "responses_websockets=2026-02-06"
const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH export const PATH = OpenResponses.PATH
@@ -57,16 +62,6 @@ const OpenAIResponsesBody = Schema.Struct({
}) })
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody> export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("response.create"),
...OpenAIResponsesCoreFields,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
const extension = { const extension = {
id: ADAPTER, id: ADAPTER,
name: NAME, name: NAME,
@@ -249,6 +244,13 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>() export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
@@ -257,36 +259,7 @@ export const route = Route.make({
protocol, protocol,
endpoint, endpoint,
auth, auth,
transport: httpTransport, transport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
const { stream: _stream, ...message } = body
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
})
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage,
})
export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint,
auth,
transport: webSocketTransport,
defaults: { providerOptions: { openai: { store: false } } }, defaults: { providerOptions: { openai: { store: false } } },
}) })
+1
View File
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
"overloaded_error", "overloaded_error",
"server_error", "server_error",
"server_is_overloaded", "server_is_overloaded",
"slow_down",
"serviceunavailableexception", "serviceunavailableexception",
]) ])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"]) const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
+2 -13
View File
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
export const id = ProviderID.make("openai") export const id = ProviderID.make("openai")
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route] export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories // This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
@@ -63,7 +63,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly organization?: string readonly organization?: string
readonly project?: string readonly project?: string
readonly queryParams?: Readonly<Record<string, string>> readonly queryParams?: Readonly<Record<string, string>>
readonly transport?: "http" | "websocket"
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
@@ -82,17 +81,12 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input) const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (id: string | ModelID) => const responses = (id: string | ModelID) =>
responsesRoute responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id }) .model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) => const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id }) chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
@@ -111,7 +105,6 @@ export const configure = (input: Config = {}) => {
id, id,
model: responses, model: responses,
responses, responses,
responsesWebSocket,
chat, chat,
image, image,
configure, configure,
@@ -138,10 +131,7 @@ const config = (settings: Settings): Config => {
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const configured = configure(config(settings)) return configure(config(settings)).responses(modelID)
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
} }
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = ( export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
@@ -149,6 +139,5 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
settings, settings,
) => configure(config(settings)).chat(modelID) ) => configure(config(settings)).chat(modelID)
export const responses = provider.responses export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
+24 -19
View File
@@ -1,12 +1,10 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import * as Option from "effect/Option"
import { Auth } from "./auth.js" import { Auth } from "./auth.js"
import { Endpoint, type EndpointPatch } from "./endpoint.js" import { Endpoint, type EndpointPatch } from "./endpoint.js"
import { RequestExecutor } from "./executor.js" import { RequestExecutor } from "./executor.js"
import { Framing } from "./framing.js" import { Framing } from "./framing.js"
import { HttpTransport } from "./transport/index.js" import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import { WebSocketExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js" import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js" import { applyCachePolicy } from "../cache-policy.js"
import * as ProviderShared from "../protocols/shared.js" import * as ProviderShared from "../protocols/shared.js"
@@ -58,6 +56,7 @@ export interface Route<Body, Prepared = unknown> {
prepared: Prepared, prepared: Prepared,
request: LLMRequest, request: LLMRequest,
runtime: TransportRuntime, runtime: TransportRuntime,
options?: StreamOptions,
) => Stream.Stream<LLMEvent, AIError> ) => Stream.Stream<LLMEvent, AIError>
} }
@@ -157,6 +156,7 @@ export interface Interface {
export interface StreamOptions { export interface StreamOptions {
readonly http?: HttpMiddleware readonly http?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
} }
export interface StreamMethod { export interface StreamMethod {
@@ -314,23 +314,29 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
middleware: options?.http, middleware: options?.http,
webSocket: options?.webSocket,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, options?: StreamOptions) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
const events = routeInput.transport return Stream.unwrap(
.frames(prepared, request, runtime) routeInput.transport.execute(prepared, request, runtime, options).pipe(
.pipe( Effect.map((execution) => {
Stream.mapEffect(decodeEvent(route)), const events = execution.frames.pipe(
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, Stream.mapEffect(decodeEvent(route)),
) protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
return events.pipe( )
Stream.mapAccumEffect( const stream = events.pipe(
() => protocol.stream.initial(request), Stream.mapAccumEffect(
protocol.stream.step, () => protocol.stream.initial(request),
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
)
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
}),
), ),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
) )
}, },
} satisfies Route<Body, Prepared> } satisfies Route<Body, Prepared>
@@ -413,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const compiled = yield* compile(request, options) const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options)
}), }),
) )
@@ -451,7 +457,6 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () { Effect.gen(function* () {
const stream = streamRequestWith({ const stream = streamRequestWith({
http: yield* RequestExecutor.Service, http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}) })
return Service.of({ stream, generate: generateWith(stream) }) return Service.of({ stream, generate: generateWith(stream) })
}), }),
+33 -111
View File
@@ -34,44 +34,8 @@ export type HttpMiddleware = (
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384 const headerDetails = (headers: Headers.Headers) =>
const REDACTED = "<redacted>" Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
// One source of truth for what counts as a sensitive name across headers,
// URL query keys, and field names embedded inside request/response bodies.
//
// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
// names like `Authorization` / `X-API-Key`) and as the body-field alternation
// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
// that are too generic to redact substring-style without false positives.
const SENSITIVE_NAME_SOURCE =
"authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
const SHORT_QUERY_NAME = /^(key|sig)$/i
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
Object.fromEntries(
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
name,
String(value),
]),
)
const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
})
return url.toString()
}
const normalizedHeaders = (headers: Headers.Headers) => const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
@@ -144,58 +108,22 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
}) })
} }
const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) => const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({ new HttpRequestDetails({
method: request.method, method: request.method,
url: redactUrl(request.url), url: request.url,
headers: redactHeaders(request.headers, redactedNames), headers: headerDetails(request.headers),
}) })
const responseDetails = ( const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
response: HttpClientResponse.HttpClientResponse,
redactedNames: ReadonlyArray<string | RegExp>,
) =>
new HttpResponseDetails({ new HttpResponseDetails({
status: response.status, status: response.status,
headers: redactHeaders(response.headers, redactedNames), headers: headerDetails(response.headers),
}) })
const secretValues = (request: HttpClientRequest.HttpClientRequest) => { const responseBody = (body: string | void) => {
const values = new Set<string>()
const add = (value: string) => {
if (value.length < 4) return
values.add(value)
values.add(encodeURIComponent(value))
}
Object.entries(request.headers).forEach(([name, value]) => {
if (!isSensitiveHeaderName(name)) return
add(value)
const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
if (bearer) add(bearer)
})
if (!URL.canParse(request.url)) return values
new URL(request.url).searchParams.forEach((value, key) => {
if (isSensitiveQueryName(key)) add(value)
})
return values
}
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
if (body === undefined) return {} if (body === undefined) return {}
const redacted = redactBody(body, secrets) return { body }
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
} }
const decodeProviderBody = Schema.decodeUnknownOption( const decodeProviderBody = Schema.decodeUnknownOption(
@@ -207,52 +135,49 @@ const decodeProviderBody = Schema.decodeUnknownOption(
), ),
) )
const providerMessage = (status: number, body: { readonly body?: string }) => { const providerMessage = (status: number, body: string | void) => {
if (body.body && body.body.length <= 500) { const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
const decoded = Option.getOrUndefined(decodeProviderBody(body.body)) return (
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}` [decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
} `Provider request failed with HTTP ${status}`
return `Provider request failed with HTTP ${status}` )
} }
const responseHttp = (input: { const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse readonly response: HttpClientResponse.HttpClientResponse
readonly redactedNames: ReadonlyArray<string | RegExp>
readonly body: ReturnType<typeof responseBody> readonly body: ReturnType<typeof responseBody>
readonly requestId?: string | undefined readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined readonly rateLimit?: HttpRateLimitDetails | undefined
}) => }) =>
new HttpContext({ new HttpContext({
request: requestDetails(input.request, input.redactedNames), request: requestDetails(input.request),
response: responseDetails(input.response, input.redactedNames), response: responseDetails(input.response),
...input.body, ...input.body,
requestId: input.requestId, requestId: input.requestId,
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
const statusError = const statusError =
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) => (request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
(response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () { Effect.gen(function* () {
if (response.status < 400) return response if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.void)) const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
const headers = normalizedHeaders(response.headers) const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, secretValues(request)) const details = responseBody(body)
return yield* new AIError({ return yield* new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
status: response.status, status: response.status,
message: providerMessage(response.status, details), message: providerMessage(response.status, body),
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
rateLimit, rateLimit,
http: responseHttp({ http: responseHttp({
request, request,
response, response,
redactedNames,
body: details, body: details,
requestId: requestId(headers), requestId: requestId(headers),
rateLimit, rateLimit,
@@ -262,10 +187,10 @@ const statusError =
}) })
// Classifies an HTTP failure captured outside the executor (for example by the // Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that // AI SDK's own fetch) onto the same reason types and HttpContext that
// executor-driven requests produce. The originating request is not available on // executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST), // that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies. // request headers are empty.
export const classifyHttpFailure = (input: { export const classifyHttpFailure = (input: {
readonly message: string readonly message: string
readonly url: string readonly url: string
@@ -277,7 +202,7 @@ export const classifyHttpFailure = (input: {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders)) const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>()) const details = responseBody(input.responseBody)
return classifyProviderFailure({ return classifyProviderFailure({
message: input.message, message: input.message,
status: input.status, status: input.status,
@@ -285,11 +210,11 @@ export const classifyHttpFailure = (input: {
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
rateLimit, rateLimit,
http: new HttpContext({ http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }), request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
response: response:
input.status === undefined input.status === undefined
? undefined ? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }), : new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details, ...details,
requestId: requestId(headers), requestId: requestId(headers),
rateLimit, rateLimit,
@@ -319,7 +244,6 @@ const httpError = (input: {
readonly error: unknown readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => { }) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) => const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
@@ -331,8 +255,8 @@ const httpError = (input: {
transport: "http", transport: "http",
operation: input.operation, operation: input.operation,
code: failure.code, code: failure.code,
url: redactUrl(request.url), url: request.url,
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }), http: new HttpContext({ request: requestDetails(request) }),
}), }),
}) })
@@ -343,7 +267,7 @@ const httpError = (input: {
const native = nativeTransportFailure(source) const native = nativeTransportFailure(source)
const code = native?.code const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined) const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined const detail = raw
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source)) if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
@@ -369,10 +293,9 @@ export const stream = (
): Stream.Stream<Uint8Array, AIError> => ): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware) const response = yield* executor.execute(request, middleware)
return response.stream.pipe( return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })), Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
) )
}), }),
) )
@@ -383,19 +306,18 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) if (!middleware)
return yield* http.execute(request).pipe( return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })), Effect.mapError((error) => httpError({ error, request, operation: "request" })),
Effect.flatMap(statusError(request, redactedNames)), Effect.flatMap(statusError(request)),
) )
const response = yield* middleware(request, (input) => const response = yield* middleware(request, (input) =>
http http
.execute(input) .execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames }))) ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
return yield* statusError(response.request, redactedNames)(response) return yield* statusError(response.request)(response)
}) })
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
+19 -2
View File
@@ -16,11 +16,28 @@ export { AuthOptions } from "./auth-options.js"
export { Endpoint } from "./endpoint.js" export { Endpoint } from "./endpoint.js"
export { Framing } from "./framing.js" export { Framing } from "./framing.js"
export { Protocol } from "./protocol.js" export { Protocol } from "./protocol.js"
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport/index.js" export { HttpTransport, WebSocketTransport } from "./transport/index.js"
export * as Transport from "./transport/index.js" export * as Transport from "./transport/index.js"
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js" export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js" export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js" export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
export type { Definition as FramingDef } from "./framing.js" export type { Definition as FramingDef } from "./framing.js"
export type { Protocol as ProtocolDef } from "./protocol.js" export type { Protocol as ProtocolDef } from "./protocol.js"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js" export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
HttpHandler,
HttpMiddleware,
Transport as TransportDef,
TransportExecuteOptions,
TransportExecution,
TransportRuntime,
WebSocketConnection,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
WebSocketConnector,
WebSocketRequest,
} from "./transport/index.js"
+4 -2
View File
@@ -87,8 +87,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, _request, runtime) => execute: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)), Effect.succeed({
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
}) })
export const sseJson = { export const sseJson = {
+30 -5
View File
@@ -1,19 +1,33 @@
import type { Effect, Stream } from "effect" import type { Effect, Scope, Stream } from "effect"
import { Endpoint } from "../endpoint.js" import { Endpoint } from "../endpoint.js"
import { Auth } from "../auth.js" import { Auth } from "../auth.js"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js" import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
import type { Interface as WebSocketExecutorInterface } from "./websocket.js" import type { WebSocketChannelExecutor } from "./websocket-channel.js"
import type { AIError, LLMRequest } from "../../schema/index.js" import type { AIError, LLMRequest } from "../../schema/index.js"
export interface TransportRuntime { export interface TransportRuntime {
readonly http: RequestExecutorInterface readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface }
export interface TransportExecution<Frame> {
readonly frames: Stream.Stream<Frame, AIError>
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
readonly complete?: Effect.Effect<void>
}
export interface TransportExecuteOptions {
readonly webSocket?: WebSocketChannelExecutor
} }
export interface Transport<Body, Prepared, Frame> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError> readonly execute: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
options?: TransportExecuteOptions,
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
} }
export interface TransportPrepareInput<Body> { export interface TransportPrepareInput<Body> {
@@ -24,8 +38,19 @@ export interface TransportPrepareInput<Body> {
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware readonly middleware?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
} }
export * as HttpTransport from "./http.js" export * as HttpTransport from "./http.js"
export type { HttpHandler, HttpMiddleware } from "../executor.js" export type { HttpHandler, HttpMiddleware } from "../executor.js"
export { WebSocketExecutor, WebSocketTransport } from "./websocket.js" export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
export { WebSocketTransport } from "./websocket.js"
@@ -0,0 +1,50 @@
import type { Effect, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import type { AIError } from "../../schema/index.js"
export interface WebSocketChannelExecutor {
readonly execute: (
exchange: WebSocketChannelExchange,
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
}
export interface WebSocketChannelExecution {
readonly frames: Stream.Stream<string, AIError>
/** Commits staged state after the decoded Route stream ends successfully. */
readonly complete: Effect.Effect<void>
}
export interface WebSocketChannelExchange {
readonly id: string
readonly connect: {
readonly url: string
readonly headers: Headers.Headers
/** Provider-safe connection age after which Core should rotate before sending. */
readonly rotateAfterMs?: number
}
readonly fallback: () => Stream.Stream<string, AIError>
readonly driver: WebSocketChannelDriver
}
export interface WebSocketChannelDriver {
readonly create: (checkpoint: ChannelCheckpoint | undefined) => Effect.Effect<ChannelCreate, AIError>
readonly observe: (create: ChannelCreate, frame: string) => Effect.Effect<ChannelObservation, AIError>
}
export interface ChannelCreate {
readonly message: string
readonly mode: "full" | "incremental"
}
export type ChannelObservation =
| { readonly type: "frame"; readonly frame: string }
| { readonly type: "completed"; readonly frame: string; readonly checkpoint?: ChannelCheckpoint }
| { readonly type: "incomplete"; readonly frame: string }
| { readonly type: "provider-failure"; readonly error: AIError }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "retry-full" }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "rotate-and-retry-full" }
export interface ChannelCheckpoint {
readonly protocol: string
readonly value: unknown
}
+216 -53
View File
@@ -1,8 +1,15 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js" import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js" import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js" import type { Transport } from "./index.js"
import type {
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export interface WebSocketRequest { export interface WebSocketRequest {
readonly url: string readonly url: string
@@ -15,24 +22,29 @@ export interface WebSocketConnection {
readonly close: Effect.Effect<void, never> readonly close: Effect.Effect<void, never>
} }
export interface Interface { export interface WebSocketConnector {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError> readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
} }
type WebSocketConstructorWithHeaders = new ( type WebSocketConstructorWithHeaders = (
url: string, url: string,
options?: { readonly headers?: Headers.Headers }, options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket ) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {} const MAX_FRAME_BYTES = 16 * 1024 * 1024
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string }, input: {
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) => ) =>
new AIError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketConnector",
method, method,
reason: new TransportReason({ reason: new TransportReason({
message, message,
@@ -40,9 +52,33 @@ const transportError = (
operation: input.operation, operation: input.operation,
url: input.url, url: input.url,
code: input.code, code: input.code,
phase: input.phase,
delivery: input.delivery,
}), }),
}) })
const annotateTransportError = (
error: AIError,
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) =>
error.reason._tag === "Transport"
? new AIError({
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
phase: input.phase,
delivery: input.delivery,
recovery: error.reason.recovery,
}),
})
: error
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message if ("message" in event && typeof event.message === "string") return event.message
return event.type return event.type
@@ -63,6 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url, url: input.url,
operation: "request", operation: "request",
code: "closed", code: "closed",
phase: "connect",
delivery: "not-sent",
}), }),
) )
} }
@@ -89,6 +127,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url, url: input.url,
operation: "request", operation: "request",
phase: "connect",
delivery: "not-sent",
}), }),
), ),
) )
@@ -101,6 +141,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url, url: input.url,
operation: "request", operation: "request",
code: String(event.code), code: String(event.code),
phase: "connect",
delivery: "not-sent",
}), }),
), ),
) )
@@ -112,7 +154,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}) })
} }
const webSocketUrl = (value: string) => export const toWebSocketUrl = (value: string) =>
Effect.try({ Effect.try({
try: () => { try: () => {
const url = new URL(value) const url = new URL(value)
@@ -131,21 +173,31 @@ const webSocketUrl = (value: string) =>
url: value, url: value,
operation: "request", operation: "request",
code: "invalid-url", code: "invalid-url",
phase: "prepare",
delivery: "not-sent",
}), }),
}) })
export const open = (input: WebSocketRequest) => export const open = (input: WebSocketRequest) =>
Effect.try({ Effect.gen(function* () {
try: () => const constructor = yield* Socket.WebSocketConstructor
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), const ws = yield* Effect.try({
catch: (error) => try: () =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { // Platform implementations may extend Effect's browser-compatible constructor with handshake options.
url: input.url, // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
operation: "request", (constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
}), headers: input.headers,
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) }),
catch: (error) =>
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open })) transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
phase: "connect",
delivery: "not-sent",
}),
})
return yield* fromWebSocket(ws, input)
})
export const fromWebSocket = ( export const fromWebSocket = (
ws: globalThis.WebSocket, ws: globalThis.WebSocket,
@@ -155,16 +207,52 @@ export const fromWebSocket = (
yield* waitOpen(ws, input) yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128) const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
const rejectOversized = (message: string | Uint8Array) => {
if (!oversized(message)) return false
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
url: input.url,
operation: "read",
code: "message-too-large",
phase: "receive",
}),
),
)
if (ws.readyState === globalThis.WebSocket.OPEN) ws.close(1009, "Message too large")
return true
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) if (typeof event.data === "string") return offer(event.data)
const binary = binaryMessage(event.data) const binary = binaryMessage(event.data)
if (binary) return Queue.offerUnsafe(messages, binary) if (binary) return offer(binary)
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { transportError("message", "Unsupported WebSocket message payload", {
url: input.url, url: input.url,
operation: "read", operation: "read",
code: "message",
phase: "receive",
}), }),
), ),
) )
@@ -176,12 +264,13 @@ export const fromWebSocket = (
transportError("message", `WebSocket error: ${eventMessage(event)}`, { transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url, url: input.url,
operation: "read", operation: "read",
code: "message",
phase: "receive",
}), }),
), ),
) )
} }
const onClose = (event: CloseEvent) => { const onClose = (event: CloseEvent) => {
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
@@ -189,6 +278,7 @@ export const fromWebSocket = (
url: input.url, url: input.url,
operation: "read", operation: "read",
code: String(event.code), code: String(event.code),
phase: "close",
}), }),
), ),
) )
@@ -205,13 +295,26 @@ export const fromWebSocket = (
return { return {
sendText: (message) => sendText: (message) =>
Effect.try({ Effect.suspend(() => {
try: () => ws.send(message), if (ws.readyState !== globalThis.WebSocket.OPEN)
catch: (error) => return Effect.fail(
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url, url: input.url,
operation: "write", operation: "write",
}), phase: "send",
delivery: "not-sent",
}),
)
return Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
phase: "send",
delivery: "not-sent",
}),
})
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
close: cleanup.pipe( close: cleanup.pipe(
@@ -228,6 +331,57 @@ export const fromWebSocket = (
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) => export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
typeof message === "string" ? message : decoder.decode(message) typeof message === "string" ? message : decoder.decode(message)
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
execute: (exchange) =>
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
connector
.open(exchange.connect)
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
(connection) => connection.close,
)
const create = yield* exchange.driver.create(undefined)
yield* connection.sendText(create.message)
const decoder = new TextDecoder()
let observed = false
return {
frames: connection.messages.pipe(
Stream.map((message) => {
observed = true
return messageText(message, decoder)
}),
Stream.mapError((error) =>
annotateTransportError(error, {
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery: observed ? "accepted" : "ambiguous",
}),
),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.void,
}
}),
})
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
function* () {
const constructor = yield* Socket.WebSocketConstructor
return makeDirect({
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
})
},
)
export interface JsonPrepared { export interface JsonPrepared {
readonly url: string readonly url: string
readonly headers: Headers.Headers readonly headers: Headers.Headers
@@ -254,33 +408,44 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
...prepareInput, ...prepareInput,
}) })
return { return {
url: yield* webSocketUrl(parts.url), url: yield* toWebSocketUrl(parts.url),
headers: parts.headers, headers: parts.headers,
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
} }
}), }),
frames: (prepared, _request, runtime) => { execute: (prepared, request, _runtime, options) => {
const webSocket = runtime.webSocket const webSocket = options?.webSocket
if (!webSocket) { if (!webSocket) {
return Stream.fail( return Effect.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
url: prepared.url, url: prepared.url,
operation: "request", operation: "request",
code: "unavailable", code: "unavailable",
phase: "prepare",
delivery: "not-sent",
}), }),
) )
} }
const decoder = new TextDecoder() const driver: WebSocketChannelDriver = {
return Stream.unwrap( create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
Effect.gen(function* () { observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
const connection = yield* Effect.acquireRelease( }
webSocket.open({ url: prepared.url, headers: prepared.headers }), const exchange: WebSocketChannelExchange = {
(connection) => connection.close, id: request.id ?? "request",
) connect: { url: prepared.url, headers: prepared.headers },
yield* connection.sendText(prepared.message) fallback: () =>
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder))) Stream.fail(
}), transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
) url: prepared.url,
operation: "request",
code: "websocket",
phase: "fallback",
delivery: "not-sent",
}),
),
driver,
}
return webSocket.execute(exchange)
}, },
}) })
@@ -289,15 +454,13 @@ export const jsonTransport = {
with: json, with: json,
} as const } as const
export const WebSocketExecutor = {
Service,
layer,
open,
fromWebSocket,
messageText,
} as const
export const WebSocketTransport = { export const WebSocketTransport = {
json, json,
jsonTransport, jsonTransport,
direct,
makeDirect,
open,
fromWebSocket,
messageText,
toWebSocketUrl,
} as const } as const
+7
View File
@@ -106,6 +106,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
code: Schema.optional(Schema.String), code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
phase: Schema.optional(
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
),
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
recovery: Schema.optional(
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
),
}) {} }) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>( export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
+209 -30
View File
@@ -1,12 +1,13 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect" import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js" import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js" import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js" import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js" import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js" import { sseEvents, sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js" import { it } from "./lib/effect.js"
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe( const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
@@ -65,6 +66,7 @@ const expectAIError = (error: unknown) => {
} }
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () => it.effect("parses response body failures at the executor seam", () =>
@@ -75,11 +77,11 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>", message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
transport: "http", transport: "http",
operation: "read", operation: "read",
code: "ECONNRESET", code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1", url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1",
}) })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -152,12 +154,12 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "Transport", _tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>", message: "ECONNRESET: proxy disconnected proxy-secret",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", url: "https://proxy.test/v1/chat?api_key=proxy-secret",
http: { http: {
request: { request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", url: "https://proxy.test/v1/chat?api_key=proxy-secret",
headers: { authorization: "<redacted>" }, headers: { authorization: "Bearer proxy-secret" },
}, },
}, },
}) })
@@ -217,9 +219,47 @@ describe("RequestExecutor", () => {
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
) )
it.effect("preserves structured provider messages from large error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
it.effect("falls back when structured provider messages are empty", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "Provider request failed with HTTP 400",
})
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () => it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
Effect.gen(function* () { Effect.gen(function* () {
const classify = (body: string) => const classify = (body: string) =>
@@ -253,7 +293,7 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("returns redacted diagnostics for rate limits", () => it.effect("returns complete diagnostics for rate limits", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
@@ -268,15 +308,15 @@ describe("RequestExecutor", () => {
requestId: "req_123", requestId: "req_123",
request: { request: {
method: "POST", method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" }, headers: { authorization: "Bearer secret", "x-safe": "visible" },
}, },
response: { response: {
status: 429, status: 429,
headers: { headers: {
"retry-after-ms": "0", "retry-after-ms": "0",
"x-request-id": "req_123", "x-request-id": "req_123",
"x-api-key": "<redacted>", "x-api-key": "secret",
}, },
}, },
}, },
@@ -295,14 +335,14 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("honors current redacted header names in diagnostics", () => it.effect("preserves configured header names in diagnostics", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
}).pipe( }).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
@@ -421,15 +461,15 @@ describe("RequestExecutor", () => {
}), }),
) )
it.effect("truncates large authentication error bodies", () => it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" }) expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
expect(errorHttp(error)?.body).toHaveLength(16_384) expect(errorHttp(error)?.body).toHaveLength(20_000)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -440,16 +480,15 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("redacts common secret fields in response bodies", () => it.effect("preserves response body fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"') expect(errorHttp(error)?.body).toBe(
expect(errorHttp(error)?.body).toContain("api_key=<redacted>") '{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
expect(errorHttp(error)?.body).not.toContain("body-secret") )
expect(errorHttp(error)?.body).not.toContain("query-secret")
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -461,16 +500,13 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("redacts echoed request secret values in response bodies", () => it.effect("preserves echoed request values in response bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip) const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>") expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
expect(errorHttp(error)?.body).not.toContain("header-secret-456")
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@@ -511,3 +547,146 @@ describe("RequestExecutor", () => {
}), }),
) )
}) })
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
]
it.effect("runs a channel driver through the direct executor", () =>
Effect.gen(function* () {
const sent = yield* Ref.make("")
const closed = yield* Ref.make(false)
const observed = yield* Ref.make(0)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) => Ref.set(sent, message),
messages: Stream.make("one", "done", "late"),
close: Ref.set(closed, true),
}),
})
const received = yield* Effect.scoped(
Effect.gen(function* () {
const execution = yield* webSocket.execute({
id: "exchange_1",
connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
fallback: () => Stream.empty,
driver: {
create: () => Effect.succeed({ message: "create", mode: "full" }),
observe: (_create, frame) =>
Ref.update(observed, (value) => value + 1).pipe(
Effect.as(
frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
),
),
},
})
return yield* Stream.runCollect(execution.frames)
}),
)
expect(Array.from(received)).toEqual(["one", "done"])
expect(yield* Ref.get(sent)).toBe("create")
expect(yield* Ref.get(observed)).toBe(2)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("rejects a closed socket before attempting to send", () =>
Effect.gen(function* () {
class ClosedBeforeSend extends EventTarget {
readyState = globalThis.WebSocket.OPEN
sends = 0
send() {
this.sends++
}
close() {}
}
const socket = new ClosedBeforeSend()
const connection = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
)
socket.readyState = globalThis.WebSocket.CLOSED
const error = yield* connection.sendText("create").pipe(Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "send", delivery: "not-sent" })
expect(socket.sends).toBe(0)
yield* connection.close
}),
)
it.effect("uses HTTP when no per-call WebSocket executor is provided", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...frames))))
expect(response.text).toBe("Hi")
}),
)
it.effect("commits channel execution only after complete consumption", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
execute: () =>
Effect.succeed({
frames: input,
complete: Ref.update(commits, (value) => value + 1),
}),
})
const response = yield* LLMClient.generate(request, {
webSocket: executor(Stream.fromArray(frames)),
}).pipe(Effect.provide(fixedResponse("")))
expect(response.text).toBe("Hi")
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
Effect.provide(fixedResponse("")),
Effect.flip,
)
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(fixedResponse("")),
)
expect(yield* Ref.get(commits)).toBe(1)
}),
)
it.effect("does not commit interrupted channel execution", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const started = yield* Deferred.make<void>()
const executor: WebSocketChannelExecutor = {
execute: () =>
Effect.succeed({
frames: Stream.fromEffect(
Deferred.succeed(started, undefined).pipe(
Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
),
).pipe(Stream.concat(Stream.never)),
complete: Ref.update(commits, (value) => value + 1),
}),
}
const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
Stream.runDrain,
Effect.provide(fixedResponse("")),
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Ref.get(commits)).toBe(0)
}),
)
})
+4 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai" import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route" import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider" import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import { import {
CloudflareAIGateway, CloudflareAIGateway,
@@ -16,6 +16,7 @@ import {
OpenAICompatibleResponses, OpenAICompatibleResponses,
OpenAIResponses, OpenAIResponses,
OpenResponses, OpenResponses,
OpenResponsesChannel,
} from "@opencode-ai/ai/protocols" } from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing" import { TestLLM } from "@opencode-ai/ai/testing"
@@ -36,6 +37,7 @@ describe("public exports", () => {
test("route barrel exposes route-authoring APIs", () => { test("route barrel exposes route-authoring APIs", () => {
expect(Route.make).toBeFunction() expect(Route.make).toBeFunction()
expect(Protocol.make).toBeFunction() expect(Protocol.make).toBeFunction()
expect(WebSocketTransport.makeDirect).toBeFunction()
}) })
test("provider barrels expose user-facing facades", async () => { test("provider barrels expose user-facing facades", async () => {
@@ -43,7 +45,6 @@ describe("public exports", () => {
expect(OpenAI.model).toBeFunction() expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAICompatible.deepseek.model).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction()
expect( expect(
@@ -65,10 +66,10 @@ describe("public exports", () => {
expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAIChat.route.id).toBe("openai-chat")
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
expect(OpenResponses.protocol.id).toBe("open-responses") expect(OpenResponses.protocol.id).toBe("open-responses")
expect(OpenResponsesChannel.transport).toBeFunction()
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses") expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.route.id).toBe("openai-responses")
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
expect(AnthropicMessages.route.id).toBe("anthropic-messages") expect(AnthropicMessages.route.id).toBe("anthropic-messages")
}) })
}) })
+4 -6
View File
@@ -1,9 +1,8 @@
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import { LLMClient, RequestExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js" import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js" import type { Service as RequestExecutorService } from "../../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket.js"
export type HandlerInput = { export type HandlerInput = {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
@@ -34,7 +33,7 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
), ),
) )
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | LLMClientService
export interface SystemError extends Error { export interface SystemError extends Error {
readonly code: string readonly code: string
@@ -44,9 +43,8 @@ export const systemError = (code: string, message: string): SystemError => Objec
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => { export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps)) return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
return Layer.mergeAll(deps, llmClientLayer)
} }
const SSE_HEADERS = { "content-type": "text/event-stream" } as const const SSE_HEADERS = { "content-type": "text/event-stream" } as const
+2 -2
View File
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
test("classifies V1 overloaded provider codes", () => { test("classifies V1 overloaded provider codes", () => {
expect( expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map( ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
(message) => classifyProviderFailure({ message })._tag, (message) => classifyProviderFailure({ message })._tag,
), ),
).toEqual(["ProviderInternal", "ProviderInternal"]) ).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
@@ -1,13 +1,18 @@
import { LLM } from "../../src/index.js" import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js" import { OpenAI } from "../../src/providers.js"
const model = OpenAI.responses("gpt-5") const selected = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ LLM.request({
model, model: selected,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string. // @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } }, providerOptions: { openai: { reasoningEffort: 1 } },
}) })
OpenAI.configure({
// @ts-expect-error Transport is execution policy, not provider configuration.
transport: "websocket",
})
@@ -80,11 +80,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
}) })
test("selects transport without changing the semantic API", () => {
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => { test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses") const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", { const selected = OpenAICompatibleResponses.model("custom-model", {
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}), }),
) )
}) })
@@ -1098,8 +1098,7 @@ describe("Anthropic Messages route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1275,8 +1275,7 @@ describe("OpenAI Chat route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
LLM, LLM,
AIError, AIError,
HttpOptions,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
Message, Message,
@@ -11,14 +12,23 @@ import {
ToolCallPart, ToolCallPart,
ToolDefinition, ToolDefinition,
ToolResultPart, ToolResultPart,
TransportReason,
Usage, Usage,
} from "../../src/index.js" } from "../../src/index.js"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import {
Auth,
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelObservation,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js" import { compileRequest } from "../../src/route/client.js"
import * as Azure from "../../src/providers/azure.js" import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js" import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js" import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js" import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
import * as ProviderShared from "../../src/protocols/shared.js" import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js" import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
@@ -31,6 +41,47 @@ const model = OpenAIResponses.route
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5") const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
create: () => Effect.succeed({ message, mode: "full" }),
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> => {
const event = ProviderShared.decodeJson(frame)
if (!ProviderShared.isRecord(event)) return Effect.die("Expected event")
if (event.type === "response.completed") return Effect.succeed({ type: "completed", frame })
if (event.type === "response.incomplete") return Effect.succeed({ type: "incomplete", frame })
if (event.type === "error" || event.type === "response.failed")
return Effect.succeed({
type: "provider-failure",
error: new AIError({
module: "test",
method: "stream",
reason: new TransportReason({
message: "provider rejected request",
transport: "websocket",
operation: "read",
phase: "receive",
}),
}),
})
return Effect.succeed({ type: "frame", frame })
},
})
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenAIResponsesChannel.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: baseChannelDriver(message),
})
}
const checkpoint = (observation: ChannelObservation) => {
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected checkpoint")
return observation.checkpoint
}
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model, model,
@@ -216,19 +267,19 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("prepares OpenAI Responses WebSocket target", () => it.effect("prepares one OpenAI Responses route for either transport", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute model: OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }), .model({ id: "gpt-4.1-mini" }),
}), }),
) )
expect(prepared.route).toBe("openai-responses-websocket") expect(prepared.route).toBe("openai-responses")
expect(prepared.protocol).toBe("openai-responses") expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "websocket-json" }) expect(prepared.metadata).toEqual({ transport: "http-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true }) expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
}), }),
) )
@@ -236,47 +287,60 @@ describe("OpenAI Responses route", () => {
it.effect("streams OpenAI Responses over WebSocket", () => it.effect("streams OpenAI Responses over WebSocket", () =>
Effect.gen(function* () { Effect.gen(function* () {
const sent: string[] = [] const sent: string[] = []
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] const opened: Array<{
readonly url: string
readonly authorization: string | undefined
readonly protocol: string | undefined
}> = []
let closed = false let closed = false
const deps = Layer.mergeAll( const deps = Layer.succeed(
Layer.succeed( RequestExecutor.Service,
RequestExecutor.Service, RequestExecutor.Service.of({
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request"),
execute: () => Effect.die("unexpected HTTP request"), }),
}),
),
Layer.succeed(
WebSocketExecutor.Service,
WebSocketExecutor.Service.of({
open: (input) =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
opened.push({ url: input.url, authorization: input.headers.authorization })
sent.push(message)
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Effect.sync(() => {
closed = true
}),
}),
}),
),
) )
const webSocket = WebSocketTransport.makeDirect({
open: (input) =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
opened.push({
url: input.url,
authorization: input.headers.authorization,
protocol: input.headers["openai-beta"],
})
sent.push(message)
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Effect.sync(() => {
closed = true
}),
}),
})
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket( model: OpenAI.configure({
"gpt-4.1-mini", baseURL: "https://api.openai.test/v1/",
), apiKey: "test",
headers: { "openai-beta": "custom-protocol" },
}).responses("gpt-4.1-mini"),
prompt: "Say hello.", prompt: "Say hello.",
}), }),
{ webSocket },
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))) ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.text).toBe("Hi") expect(response.text).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }]) expect(opened).toEqual([
{
url: "wss://api.openai.test/v1/responses",
authorization: "Bearer test",
protocol: "custom-protocol",
},
])
expect(closed).toBe(true) expect(closed).toBe(true)
expect(sent).toHaveLength(1) expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0])).toEqual({ expect(JSON.parse(sent[0])).toEqual({
@@ -288,15 +352,524 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("rejects out-of-order and mismatched WebSocket response events", () =>
Effect.gen(function* () {
const streams = [
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "late", delta: "Late" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_new" } }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
]
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["late", "mismatch"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual(["InvalidProviderOutput", "InvalidProviderOutput"])
expect(errors[0]?.message).toContain("before response.created")
expect(errors[1]?.message).toContain("response ID changed")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
}),
)
it.effect("uses a full request when any non-input invariant changes", () =>
Effect.gen(function* () {
const request = {
type: "response.create",
model: "gpt-5.2",
store: false,
metadata: { source: "one" },
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [...request.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
{ ...request, tools: [{ type: "function", name: "other" }], input: appended },
{ ...request, temperature: 0.5, input: appended },
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [{ role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
},
]
const creates = yield* Effect.forEach(changes, (changed) => continuationDriver(changed).create(saved))
expect(creates.map((item) => item.mode)).toEqual(changes.map(() => "full"))
expect(
creates
.map((item) => ProviderShared.decodeJson(item.message))
.every((item) => ProviderShared.isRecord(item) && !("previous_response_id" in item)),
).toBe(true)
}),
)
it.effect("stages no checkpoint for incomplete or ID-less completion", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const completed = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: {} }),
)
expect(completed).toMatchObject({ type: "completed" })
expect(completed).not.toHaveProperty("checkpoint")
expect(
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.incomplete", response: {} })),
).toMatchObject({ type: "incomplete" })
}),
)
it.effect("classifies explicit continuation rejection for runner-owned recovery", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const missing = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "previous_response_not_found", message: "Missing response" },
}),
)
const limit = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "websocket_connection_limit_reached", message: "Rotate" },
}),
)
expect(missing).toMatchObject({
type: "rejected",
recovery: "retry-full",
error: { reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" } },
})
expect(limit).toMatchObject({
type: "rejected",
recovery: "rotate-and-retry-full",
error: {
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
},
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const message = yield* Ref.make("")
const body = yield* Ref.make("")
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
http: {
body: {
model: "overlaid-model",
metadata: { source: "overlay" },
stream_options: { include_usage: true },
background: true,
},
headers: { "x-request": "request" },
query: { mode: "test" },
},
}),
{
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers["openai-beta"]).toBe("responses_websockets=2026-02-06")
expect(exchange.connect.headers["content-length"]).toBeUndefined()
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
yield* Ref.set(body, input.text)
expect(input.request.url).toBe("https://api.openai.test/v1/responses?mode=test")
expect(input.request.headers.authorization).toBe("Bearer test")
expect(input.request.headers["x-request"]).toBe("request")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
const httpBody = JSON.parse(yield* Ref.get(body))
const { stream: _stream, stream_options: _streamOptions, background: _background, ...shared } = httpBody
expect(response.finishReason?.normalized).toBe("stop")
expect(yield* Ref.get(attempts)).toBe(1)
expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
expect(httpBody).toMatchObject({
model: "overlaid-model",
metadata: { source: "overlay" },
stream: true,
stream_options: { include_usage: true },
background: true,
})
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
yield* LLMClient.generate(
LLMRequest.update(request, { http: new HttpOptions({ body: { input: "raw-http-input" } }) }),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
expect(JSON.parse(input.text).input).toBe("raw-http-input")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("closes a direct WebSocket execution after partial consumption", () =>
Effect.gen(function* () {
const closed = yield* Ref.make(false)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Ref.set(closed, true),
}),
})
yield* LLMClient.stream(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
}),
{ webSocket },
).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
Effect.gen(function* () {
const events = [
{ type: "error", error: { code: "slow_down", message: "Try later" } },
{
type: "error",
status_code: 429,
message: "Rate limited",
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
},
{
type: "response.failed",
response: { error: { code: "server_error", message: "Unavailable" } },
},
{ type: "error", status: "not-a-status", message: "Malformed status" },
]
const errors = yield* Effect.forEach(events, (event) =>
LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
),
prompt: "Say hello.",
}),
{
webSocket: WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
close: Effect.void,
}),
}),
},
).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual([
"ProviderInternal",
"RateLimit",
"ProviderInternal",
"UnknownProvider",
])
}),
)
it.effect("marks post-send WebSocket failures with delivery state", () =>
Effect.gen(function* () {
const failure = new AIError({
module: "test",
method: "receive",
reason: new TransportReason({
message: "socket closed",
transport: "websocket",
operation: "read",
phase: "close",
}),
})
const streams = [
Stream.fail(failure),
Stream.make(ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_observed" } })).pipe(
Stream.concat(Stream.fail(failure)),
),
]
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason)).toEqual([
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
])
}),
)
it.effect("fails immediately when WebSocket is already closed", () => it.effect("fails immediately when WebSocket is already closed", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* WebSocketExecutor.fromWebSocket( const error = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket, { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty }, { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
).pipe(Effect.flip) ).pipe(Effect.flip)
expect(error.message).toContain("closed before opening") expect(error.message).toContain("closed before opening")
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
}), }),
) )
@@ -329,7 +902,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -2037,8 +2610,7 @@ describe("OpenAI Responses route", () => {
) )
expect(error).toBeInstanceOf(AIError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
expect(error.message).toContain("HTTP 400")
}), }),
) )
}) })
+5 -7
View File
@@ -2,12 +2,11 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Layer } from "effect" import { Layer } from "effect"
import * as path from "node:path" import * as path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
import { ImageClient } from "../src/image-client.js" import { ImageClient } from "../src/image-client.js"
import type { Service as ImageClientService } from "../src/image-client.js" import type { Service as ImageClientService } from "../src/image-client.js"
import type { Service as LLMClientService } from "../src/route/client.js" import type { Service as LLMClientService } from "../src/route/client.js"
import type { Service as RequestExecutorService } from "../src/route/executor.js" import type { Service as RequestExecutorService } from "../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket.js"
import { import {
recordedEffectGroup, recordedEffectGroup,
type RecordedCaseOptions as RunnerCaseOptions, type RecordedCaseOptions as RunnerCaseOptions,
@@ -17,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & { type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions readonly options?: HttpRecorder.RecorderOptions
@@ -82,11 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}), }),
), ),
) )
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll( return Layer.mergeAll(
deps, requestExecutor,
LLMClient.layer.pipe(Layer.provide(deps)), LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(deps)), ImageClient.layer.pipe(Layer.provide(requestExecutor)),
) )
}, },
}) })
+23
View File
@@ -11,6 +11,7 @@ import {
LanguageModel, LanguageModel,
ModelID, ModelID,
ProviderID, ProviderID,
TransportReason,
Usage, Usage,
} from "../src/schema/index.js" } from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js" import { ProviderShared } from "../src/protocols/shared.js"
@@ -108,3 +109,25 @@ test("AI errors expose the shared runtime tag", async () => {
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))), await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught") ).toBe("caught")
}) })
test("transport errors serialize execution facts", () => {
const reason = new TransportReason({
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
_tag: "Transport",
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
})
+2
View File
@@ -22,6 +22,8 @@
## Localization ## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. - Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
@@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => {
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" }) const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible() await expect(sidebar).toBeVisible()
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(sidebar).toBeHidden() await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" }) const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused() await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click() await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0) await expect(sidebar).toHaveCount(0)
@@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => {
expect(resultID).toBeTruthy() expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter") await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click() await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click() await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
}) })
function fileNode(path: string) { function fileNode(path: string) {
@@ -27,6 +27,30 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
}) })
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
() => release.resolve(),
)
await expectSessionTitle(page, taskDescription)
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = [] const events: EventPayload[] = []
await setup(page, () => events.splice(0, 1)) await setup(page, () => events.splice(0, 1))
+39 -8
View File
@@ -267,16 +267,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
} }
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE") if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project]) if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current") if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config)) if (path === "/api/location") return json(route, location(config))
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1] if (worktree && route.request().method() === "POST") {
if (projectCopy && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string } const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` }) return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
} }
if (projectCopy && route.request().method() === "DELETE") if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request") if (path === "/api/permission/request")
return json(route, { return json(route, {
@@ -343,7 +361,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const limit = Number(url.searchParams.get("limit") ?? 50) const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0) const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions const sessions = config.sessions
.filter((session) => !directory || session.directory === directory) .filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => parentID !== "null" || session.parentID === undefined) .filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => { .filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase() const search = url.searchParams.get("search")?.toLowerCase()
@@ -578,6 +599,7 @@ function currentPermission(value: unknown) {
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) { export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {} const time = session.time && typeof session.time === "object" ? session.time : {}
const location = session.location && typeof session.location === "object" ? session.location : {}
return { return {
id: session.id, id: session.id,
parentID: session.parentID, parentID: session.parentID,
@@ -595,10 +617,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
}, },
title: session.title ?? session.id, title: session.title ?? session.id,
location: { location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, directory:
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), "directory" in location && typeof location.directory === "string"
? location.directory
: typeof session.directory === "string"
? session.directory
: fallbackDirectory,
...(typeof session.workspaceID === "string"
? { workspaceID: session.workspaceID }
: "workspaceID" in location && typeof location.workspaceID === "string"
? { workspaceID: location.workspaceID }
: {}),
}, },
subpath: session.path, subpath: session.subpath ?? session.path,
revert: session.revert, revert: session.revert,
} }
} }
-6
View File
@@ -63,19 +63,13 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3", "@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
+74 -259
View File
@@ -1,48 +1,38 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect, createRenderEffect,
createResource,
createSignal,
ErrorBoundary, ErrorBoundary,
For,
type JSX, type JSX,
lazy, lazy,
onCleanup,
type ParentProps, type ParentProps,
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@@ -50,26 +40,26 @@ import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home" import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => { const DirectoryDraftRedirect = () => {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const server = useServer() const server = useServers()
const tabs = useTabs() const tabs = useTabs()
createEffect(() => { createEffect(() => {
if (search.draftId || !tabs.ready()) return if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir) const directory = decode64(params.dir)
if (!directory) return if (!directory) return
tabs.newDraft({ server: server.key, directory }, search.prompt) tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
}) })
return null return null
@@ -86,36 +76,13 @@ function TargetServerRoute(props: ParentProps) {
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn()}> <Show when={conn()} keyed>
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider> {(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</ServerSDKProvider> </Show>
</Show> </Show>
) )
} }
function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
@@ -138,19 +105,21 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
return ( return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn()}> <Show when={conn()} keyed>
<ServerSyncProvider server={conn()}> {(conn) => (
<ModelsProvider directory={props.draft.directory}> <ServerProvider conn={conn}>
<SDKProvider directory={props.draft.directory}> <ModelsProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}> <SDKProvider directory={props.draft.directory}>
<DraftProviders> <DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<NewSession /> <DraftProviders>
</DraftProviders> <NewSession draftId={props.draft.draftID} />
</DirectoryDataProvider> </DraftProviders>
</SDKProvider> </DirectoryDataProvider>
</ModelsProvider> </SDKProvider>
</ServerSyncProvider> </ModelsProvider>
</ServerSDKProvider> </ServerProvider>
)}
</Show>
</Show> </Show>
) )
} }
@@ -159,7 +128,13 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return ( return (
<I18nProvider <I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }} value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
> >
{props.children} {props.children}
</I18nProvider> </I18nProvider>
@@ -183,8 +158,8 @@ function QueryProvider(props: ParentProps) {
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false,
}, },
}, },
}) })
@@ -204,18 +179,6 @@ function BodyDesignClass() {
// Server-agnostic providers shared across every route. These live in the shared // Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</>
)
}
function DesktopCommands() { function DesktopCommands() {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -239,30 +202,14 @@ function DesktopCommands() {
return null return null
} }
type ServerScopedShellProps = ParentProps<{ function AppLayout(props: ParentProps) {
directory?: string
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<LayoutProvider> <LayoutProvider>
{props.serverScoped} <Layout>{props.children}</Layout>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider> </LayoutProvider>
) )
} }
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider. // The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) { function DraftProviders(props: ParentProps) {
@@ -312,133 +259,6 @@ export function AppBaseProviders(
) )
} }
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
</Show>
</>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
@@ -447,65 +267,60 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
startup?: Promise<void> startup?: Promise<void>
serverScoped?: JSX.Element
}) { }) {
// The visual layout lives in the router root so it remains mounted across // The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data // route changes. Draft and session routes override only their server-bound data
// providers beneath it. // providers beneath it.
const ServerShell = (shellProps: ParentProps) => ( const Root = (rootProps: ParentProps) => (
<SharedProviders> <TabsProvider>
{props.children} <BodyDesignClass />
{shellProps.children} <CommandProvider>
</SharedProviders> <DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
) )
return ( return (
<ServerProvider <ServersProvider
defaultServer={props.defaultServer} defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer} canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <SettingsProvider>
<SettingsProvider> <GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}> <Dynamic component={props.router ?? Router} root={Root}>
<Dynamic {/* Proper Routes */}
component={props.router ?? Router} <Route component={AppLayout}>
root={(routerProps) => ( <Route path="/" component={Home} />
<TabsProvider> <Route
<PermissionProvider> path="/server/:serverKey/session/:id"
<NotificationProvider> component={() => (
<ServerShell> <TargetServerRoute>
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout> <TargetSessionRouteContent />
</ServerShell> </TargetServerRoute>
</NotificationProvider> )}
</PermissionProvider> />
</TabsProvider> <Route path="/new-session" component={DraftRoute} />
)} </Route>
> {/* Legacy Routes */}
<Routes /> <Route>
</Dynamic> <Route path="/:dir" component={DirectoryDraftRedirect} />
</ConnectionGate> <Route path="/:dir/session" component={DirectoryDraftRedirect} />
</SettingsProvider> <Route path="/:dir/session/:id" component={LegacySessionRedirect} />
</GlobalProvider> </Route>
</ServerProvider> </Dynamic>
) </GlobalProvider>
} </SettingsProvider>
</ServersProvider>
function Routes() {
return (
<>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route path="/new-session" component={DraftRoute} />
</>
) )
} }
function LegacySessionRedirect() { function LegacySessionRedirect() {
const server = useServer() const server = useServers()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
@@ -516,7 +331,7 @@ function LegacySessionRedirect() {
legacySessionServer( legacySessionServer(
tabs.store.filter((item) => item.type === "session"), tabs.store.filter((item) => item.type === "session"),
params.id, params.id,
server.key, ServerConnection.key(server.list[0]!),
), ),
params.id, params.id,
)} )}
@@ -8,12 +8,13 @@ import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout" import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -83,7 +84,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const language = useLanguage() const language = useLanguage()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const serverSDK = useServerSDK()() const serverSDK = useServerSDK()
const serverCtx = global.ensureServerCtx(serverSDK.server) const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs() const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout() const { tabs: sessionTabs } = useSessionLayout()
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -387,7 +387,7 @@ function ProviderConnection(props: {
id: props.provider, id: props.provider,
name: name:
providers.all().get(props.provider)?.name ?? providers.all().get(props.provider)?.name ??
serverSync().data.provider.all.get(props.provider)?.name ?? serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ?? controller.integration()?.name ??
props.provider, props.provider,
})) }))
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync().data.config.disabled_providers ?? [], disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()), existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
@@ -10,8 +10,8 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show, createSignal, startTransition } from "solid-js" import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project" import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions" import { ProjectSettingsExtensions } from "./project-settings-extensions"
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import type { Path } from "@/types" import type { Path } from "@/types"
import { import {
absoluteTreePath, absoluteTreePath,
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types" import type { Path } from "@/types"
@@ -9,7 +9,6 @@ import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -29,14 +28,13 @@ type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
if (!filesOnly() && settings.general.newLayoutDesigns()) { if (!filesOnly()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} /> return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
} }
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { if (filesOnly() && platform.platform === "desktop") {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} /> return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
} }
@@ -52,7 +50,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
return ( return (
<DialogSelectFileV2 <DialogSelectFileV2
server={serverSDK().server} server={serverSDK.server}
mode="file" mode="file"
start={projectDirectory()} start={projectDirectory()}
title={language.t("session.header.searchFiles")} title={language.t("session.header.searchFiles")}
@@ -1,17 +1,13 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js" import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { useSettings } from "@/context/settings" import { ServerCollectionController } from "@/components/server/server-management-controller"
import { type ServerDomainController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = { type ServerConnectionFormController = {
state: { state: {
@@ -117,12 +113,11 @@ function ServerForm(props: ServerFormProps) {
} }
export function ServerConnectionList(props: { export function ServerConnectionList(props: {
domain: ServerDomainController domain: ServerCollectionController
onAdd: () => void onAdd: () => void
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
@@ -136,9 +131,6 @@ export function ServerConnectionList(props: {
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}}
divider={true} divider={true}
> >
{(i) => { {(i) => {
@@ -163,15 +155,6 @@ export function ServerConnectionList(props: {
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}> <Show when={i.type === "http"}>
<DropdownMenu> <DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,4 +1,4 @@
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import type { Platform } from "@/context/platform" import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) { export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
@@ -1,5 +1,5 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { lazy } from "solid-js" import { lazy } from "solid-js"
+1 -1
View File
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global" const supported = !props.project.id || props.project.id === "global"
@@ -90,7 +90,7 @@ export const ProjectSettingsExtensions: Component = () => {
{ initialValue: {} }, { initialValue: {} },
) )
const globalMcpNames = createMemo(() => const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(), [...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
) )
const projectMcpNames = createMemo(() => { const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames()) const shared = new Set(globalMcpNames())
@@ -102,7 +102,7 @@ export const ProjectSettingsExtensions: Component = () => {
}) })
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName)) const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => { const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins()) const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name)) return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean borderUnderlay?: boolean
accentSubmit?: boolean
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay} borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading} variantControlVisible={!props.controller.model.loading}
+6 -2
View File
@@ -45,7 +45,6 @@ import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -1518,6 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
dir={store.mode === "normal" ? "auto" : "ltr"}
contenteditable="true" contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"}
@@ -1539,7 +1539,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&_[data-type=agent]]:text-syntax-type": true, "[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
style={{ "padding-bottom": space }} style={{
"padding-bottom": space,
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
"text-align": "start",
}}
/> />
<div <div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
@@ -1,18 +1,16 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt" import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local" import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const sessionCreateInputs: Array<{ type SessionCreateInput = {
agent?: string agent?: string
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
}> = [] }
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -22,11 +20,9 @@ const optimistic: Array<{
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = [] const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = [] const sentPrompts: string[] = []
const promptInputs: unknown[] = [] const promptInputs: unknown[] = []
@@ -37,15 +33,29 @@ const switchedModels: Array<{
model: { id: string; providerID: string; variant?: string } model: { id: string; providerID: string; variant?: string }
}> = [] }> = []
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const commands: Array<{ name: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -73,21 +83,25 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: () => prompt, capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
} }
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory)
return { return {
api: { api: {
session: { session: {
create: async (input: (typeof sessionCreateInputs)[number]) => { create: async (input: SessionCreateInput) => {
await createSessionGate await createSessionGate
const location = input.location?.directory ?? directory const location = input.location?.directory ?? directory
createdSessions.push(location) createdSessions.push(location)
sessionCreateInputs.push(input) const id = `session-${createdSessions.length}`
sessionDirectories[id] = location
return { return {
id: `session-${createdSessions.length}`, id,
projectID: "project", projectID: "project",
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
@@ -100,7 +114,7 @@ const clientFor = (directory: string) => {
}, },
prompt: async (input: unknown) => { prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt") sessionRequestOrder.push("prompt")
sentPrompts.push(directory) sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input) promptInputs.push(input)
return { data: undefined } return { data: undefined }
}, },
@@ -120,6 +134,21 @@ const clientFor = (directory: string) => {
}, },
shell: async (input: { sessionID: string; id?: string; command: string }) => { shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input) sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
worktree: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: {
get: async () => {
if (locationFailure) throw locationFailure
return { directory: worktreeDirectory }
}, },
}, },
}, },
@@ -127,9 +156,6 @@ const clientFor = (directory: string) => {
command: async () => ({ data: undefined }), command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
} }
} }
@@ -145,6 +171,7 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0, showToast: () => 0,
})) }))
@@ -162,20 +189,13 @@ beforeAll(async () => {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: { session: {
promote(directory: string, sessionID: string) { promote: () => undefined,
promoted.push({ directory, sessionID })
},
}, },
}), }),
})) }))
mock.module("@/context/permission", () => { mock.module("@/context/permission", () => {
const state = (server: string) => ({ return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
}) })
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
@@ -184,7 +204,10 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
draft: () => ({ server: "project-server" }), draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session }) promotedDrafts.push({ draftID, ...session })
}, },
@@ -195,78 +218,72 @@ beforeAll(async () => {
usePrompt: () => prompt, usePrompt: () => prompt,
})) }))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
const sdk = { return () => ({
scope: "local", scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: "/repo/main", directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
api: rootClient.api, api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
} })
return () => sdk
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => ({ useSync: () => () => {
data: { command: commands }, const server = activeDirectorySync
session: { return {
optimistic: { data: { command: commands, project: "project" },
add: (value: { session: {
directory?: string optimistic: {
sessionID?: string add: (value: {
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } directory?: string
}) => { sessionID?: string
optimistic.push(value) message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
optimisticSeeded.push( }) => {
!!value.directory && optimisticServers.push(server)
!!value.sessionID && optimistic.push(value)
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, },
) remove: () => undefined,
}, },
remove: () => undefined,
}, },
}, set: () => undefined,
set: () => undefined, project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}), }
},
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({ useServerSync: () => {
session: { const server = activeServerSync
remember: () => undefined, return {
set: () => undefined, session: {
sync: async () => { remember: () => undefined,
serverSessionSyncs++ set: () => undefined,
}, sync: async () => {
}, serverSessionSyncs++
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
}, },
] },
}, child: (directory: string) => {
}), syncedServers.push(server)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
})) }))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -286,205 +303,141 @@ beforeAll(async () => {
}) })
beforeEach(() => { beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0 sentPrompts.length = 0
promptInputs.length = 0 promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0 switchedAgents.length = 0
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
commands.length = 0 syncedServers.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] optimisticServers.length = 0
promptCaptures.length = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 sentShellDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
permissionServer = "server-a" activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0 serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
const event = { preventDefault: () => undefined } as unknown as Event
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
...overrides,
})
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => { test("admits only one concurrent new-workspace submission", async () => {
const submit = createPromptSubmit({ selected = "create"
prompt, let release = () => {}
info: () => undefined, createWorktreeGate = new Promise<void>((resolve) => {
imageAttachments: () => [], release = resolve
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const submit = makeSubmit()
const event = { preventDefault: () => undefined } as unknown as Event const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
await submit.handleSubmit(event) release()
selected = "/repo/worktree-b" await Promise.all([first, duplicate])
await submit.handleSubmit(event) expect(createdSessions).toEqual([worktreeDirectory])
await settle()
expect(createdClients).toEqual([]) expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toHaveLength(1)
expect(sessionCreateInputs).toEqual([ expect(sentPrompts).toEqual([worktreeDirectory])
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => { test("stops when the created workspace cannot initialize", async () => {
const submit = createPromptSubmit({ selected = "create"
prompt, locationFailure = new Error("initialization failed")
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event await makeSubmit().handleSubmit(event)
await submit.handleSubmit(event) expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(sentPrompts).toEqual([])
}) })
test("keeps auto-accept bound to the submission server", async () => { test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {} let release = () => {}
createSessionGate = new Promise<void>((resolve) => { createSessionGate = new Promise<void>((resolve) => {
release = resolve release = resolve
}) })
const submit = createPromptSubmit({ let submitted = 0
prompt, const submit = makeSubmit({
info: () => undefined, onSubmit: () => submitted++,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) const result = submit.handleSubmit(event)
permissionServer = "server-b" activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release() release()
await result await result
await settle()
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
}) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
test("promotes drafts using the selected project's server", async () => { expect(optimisticServers).toEqual(["server-a"])
search = { draftId: "draft-1" } expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
const submit = createPromptSubmit({ expect(submitted).toBe(0)
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
}) })
test("switches the selected agent and model before prompting", async () => { test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
const submit = createPromptSubmit({ const submit = makeSubmit({
prompt,
info: () => ({ info: () => ({
id: "session-1", id: "session-1",
agent: "old-agent", agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" }, model: { id: "old-model", providerID: "old-provider" },
}), }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
@@ -519,24 +472,12 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" }) commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({ const submit = makeSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
}) })
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([ expect(sentCommands).toEqual([
{ {
@@ -552,66 +493,19 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0) expect(serverSessionSyncs).toBe(0)
}) })
test("uses an injected model selection", async () => { test("sends an initial shell after synchronous workspace creation", async () => {
params = { id: "session-1" } selected = "create"
const model = { const submit = makeSubmit({
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), mode: () => "shell",
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
}) })
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await settle()
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) expect(sentShell[0]).toMatchObject({
expect(optimisticSeeded).toEqual([true]) sessionID: "session-1",
command: "ls",
})
}) })
}) })
+221 -294
View File
@@ -8,14 +8,12 @@ import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path" import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
@@ -25,12 +23,7 @@ import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store" import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = { const submitting = new Set<string>()
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = { export type FollowupDraft = {
sessionID: string sessionID: string
@@ -50,7 +43,6 @@ type FollowupSendInput = {
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
} }
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -70,22 +62,11 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
} }
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ") const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy() setBusy()
try { try {
if (!(await wait())) {
setIdle()
return false
}
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
await input.api.command({ await input.api.command({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
@@ -159,14 +140,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
try { try {
if (!(await wait())) {
batch(() => {
setIdle()
remove()
})
return false
}
const session = input.session() const session = input.session()
if (session?.agent !== input.draft.agent) { if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent }) await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
@@ -258,13 +231,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const prompt = input.prompt const prompt = input.prompt
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
@@ -278,19 +248,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
return Promise.resolve()
}
return sdk() return sdk()
.api.session.interrupt({ sessionID }) .api.session.interrupt({ sessionID })
.catch(() => {}) .catch(() => {})
@@ -319,9 +280,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (dir: string, info: SessionInfo) => { const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
serverSync().session.remember(info) target.session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => { setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
@@ -353,7 +314,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort() if (input.working()) void abort()
return return
} }
const modelSelection = input.model ?? local.model const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current() const currentModel = modelSelection.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
@@ -366,284 +326,251 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
input.addToHistory(currentPrompt, mode) const submissionSDK = sdk()
input.resetHistoryNavigation() const submissionSync = sync()
const submissionServerSync = serverSync
const projectDirectory = sdk().directory const submissionScope = submissionSDK.scope
const permissionState = permission.currentServerState() const projectDirectory = submissionSDK.directory
const isNewSession = !params.id const sessionID = params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
const permissionState = permission
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
let sessionDirectory = projectDirectory try {
if (isNewSession) { input.addToHistory(currentPrompt, mode)
if (worktreeSelection === "create") { input.resetHistoryNavigation()
const createdWorktree = await sdk()
.api.projectCopy.create({ let sessionDirectory = projectDirectory
projectID: sync().data.project, if (isNewSession) {
strategy: "git_worktree", if (worktreeSelection === "create") {
directory: getDirectory(projectDirectory), const createdWorktree = await submissionSDK.api.worktree
location: { directory: projectDirectory }, .create({
projectID: submissionSync.data.project,
strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
})
.then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
})
if (!createdWorktree) return
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err), description: errorMessage(err),
}) })
return undefined return undefined
}) })
if (!createdWorktree) return if (created) {
WorktreeState.pending(sdk().scope, createdWorktree.directory) seed(submissionServerSync, sessionDirectory, created)
sessionDirectory = createdWorktree.directory session = created
} await startTransition(() => {
if (!session) return
if (worktreeSelection !== "main" && worktreeSelection !== "create") { if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
sessionDirectory = worktreeSelection if (!draftID) resetWorktree?.()
} if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
localSession.promote(sessionDirectory, session.id, {
if (sessionDirectory !== projectDirectory) { agent: currentAgent.name,
serverSync().child(sessionDirectory) model: { providerID: currentModel.provider.id, modelID: currentModel.id },
} variant: variant ?? null,
})
input.onNewSessionWorktreeReset?.() if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
} else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(
let session = input.info() capturePrompt(
if (!session && isNewSession) { { dir: base64Encode(sessionDirectory), id: session.id },
const created = await sdk() { server: draftServer, scope: submissionScope },
.api.session.create({ ),
agent: currentAgent.name, )
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
}) })
return undefined }
})
if (created) {
seed(sessionDirectory, created)
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
} }
} if (!session) {
if (!session) { showToast({
showToast({ title: language.t("prompt.toast.promptSendFailed.title"),
title: language.t("prompt.toast.promptSendFailed.title"), description: language.t("prompt.toast.promptSendFailed.description"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
}) })
.catch((err) => { return
showToast({ }
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) { const model = {
const [cmdName, ...args] = text.split(" ") modelID: currentModel.id,
const commandName = cmdName.slice(1) providerID: currentModel.provider.id,
const customCommand = sync().data.command.find((c) => c.name === commandName) }
if (customCommand) { const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput() clearInput()
const messageID = Identifier.ascending("message") return
serverSync().session.set("session_status", session.id, { type: "busy" }) }
sdk()
.api.session.command({ if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
.shell({
sessionID: session.id, sessionID: session.id,
id: messageID, id: eventID,
command: commandName, command: text,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
}) })
.catch((err) => { .catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.shellSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: errorMessage(err),
}) })
restoreInput() restoreInput()
}) })
return return
} }
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) if (text.startsWith("/")) {
const messageID = Identifier.ascending("message") const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const removeOptimisticMessage = () => { const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
sync().session.optimistic.remove({ if (customCommand) {
directory: sessionDirectory, clearInput()
sessionID: session.id, const messageID = Identifier.ascending("message")
messageID, submissionServerSync.session.set("session_status", session.id, { type: "busy" })
}) void submissionSDK.api.session
} .command({
sessionID: session.id,
for (const item of commentItems) submission.target().context.remove(item.key) id: messageID,
clearInput() command: commandName,
arguments: args.join(" "),
const waitForWorktree = async () => { agent,
const worktree = WorktreeState.get(sdk().scope, sessionDirectory) model: { id: model.modelID, providerID: model.providerID, variant },
if (!worktree || worktree.status !== "pending") return true files: await Promise.all(
images.map(async (attachment) => ({
if (sessionDirectory === projectDirectory) { uri: await blobDataUrl(attachment.blob, attachment.mime),
sync().set("session_status", session.id, { type: "busy" }) name: attachment.filename,
} })),
),
const controller = new AbortController() })
const cleanup = () => { .catch((err) => {
if (sessionDirectory === projectDirectory) { submissionServerSync.session.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" }) showToast({
} title: language.t("prompt.toast.commandSendFailed.title"),
removeOptimisticMessage() description: formatServerError(err, language.t, language.t("common.requestFailed")),
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) })
} restoreInput()
})
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return return
} }
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
}
void sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
session: () => input.info() ?? session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" })
} }
showToast({
title: language.t("prompt.toast.promptSendFailed.title"), const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
description: errorMessage(err), const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
session: () => session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}) })
removeOptimisticMessage() } finally {
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) submitting.delete(submissionKey)
}) }
} }
return { return {
@@ -1,35 +1,52 @@
import { For, Show } from "solid-js" import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
export function PromptWorkspaceSelector(props: { export function PromptWorkspaceSelector(props: {
value: string value: string
projectRoot: string projectRoot: string
workspaces: string[] workspaces: string[]
branch?: string branch?: string
onboarding?: boolean
onChange: (value: string) => void onChange: (value: string) => void
onDone: () => void onDone: () => void
onViewAll: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
let pending: string | undefined const [search, setSearch] = createSignal("")
const selected = () => (props.value === props.projectRoot ? "main" : props.value) let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => { const icon = () => {
if (selected() === "main") return "monitor" if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new" if (selected() === "create") return "workspace-new"
return "workspace" return "workspace-isolated"
} }
const select = (value: string) => { const select = (value: string) => {
pending = value pending = { type: "select", value }
} }
const onOpenChange = (open: boolean) => { const onOpenChange = (open: boolean) => {
if (open) return if (open) {
const value = pending setSearch("")
return
}
const action = pending
pending = undefined pending = undefined
if (value) props.onChange(value) if (action?.type === "select") props.onChange(action.value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone() props.onDone()
} }
const label = () => { const label = () => {
@@ -41,87 +58,214 @@ export function PromptWorkspaceSelector(props: {
return ( return (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}> <TooltipV2
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"> placement="top"
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" /> openDelay={800}
<span class="min-w-0 truncate">{label()}</span> value={
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> props.onboarding ? (
</MenuV2.Trigger> <div class="flex flex-col gap-1 text-start">
<MenuV2.Portal> <div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<MenuV2.Content class="w-[180px]"> <Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<MenuV2.Group> <span>{language.t("workspace.onboarding.title")}</span>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel> </div>
<MenuV2.Item onSelect={() => select("main")}> <span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
<IconV2 name="monitor" /> </div>
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span> ) : (
<Show when={selected() === "main"}> language.t("session.new.workspace.trigger.tooltip")
<Icon name="check" size="small" class="shrink-0" /> )
</Show> }
</MenuV2.Item> contentClass={props.onboarding ? "max-w-[280px]" : undefined}
<MenuV2.Item onSelect={() => select("create")}> class="min-w-0"
<IconV2 name="workspace-new" /> >
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span> <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Show when={selected() === "create"}> <MenuV2.Trigger
<Icon name="check" size="small" class="shrink-0" /> aria-description={language.t("session.new.workspace.trigger.tooltip")}
</Show> class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
</MenuV2.Item> >
</MenuV2.Group> <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.workspaces.length > 0}> <span class="min-w-0 truncate">{label()}</span>
<MenuV2.Separator /> <Show when={props.onboarding}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}> <span
<MenuV2.SubTrigger> data-slot="workspace-onboarding-dot"
<IconV2 name="workspace" /> aria-hidden="true"
{language.t("session.new.workspace.existing")} class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
</MenuV2.SubTrigger> />
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show> </Show>
</MenuV2.Content> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Portal> </MenuV2.Trigger>
</MenuV2> <MenuV2.Portal>
<PromptGitStatus branch={props.branch} /> <MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage() const language = useLanguage()
const label = () => { const label = () => {
if (props.noGit) return language.t("session.new.git.none") if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch return props.branch
} }
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return ( return (
<Show when={label()}> <Show when={label()}>
{(value) => ( {(value) => (
<> <TooltipV2
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> placement="top"
<TooltipV2 value={value()}
placement="top" class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
value={value()} contentClass="max-w-[calc(100vw-32px)] break-all"
class="min-w-0 max-w-[220px]" >
contentClass="max-w-[calc(100vw-32px)] break-all" <div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
> <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]"> <span class="min-w-0 truncate">{value()}</span>
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" /> </div>
<span class="min-w-0 truncate">{value()}</span> </TooltipV2>
</div>
</TooltipV2>
</>
)} )}
</Show> </Show>
) )
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
const [integration] = createResource( const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }), () => ({ provider: options.provider(), directory: options.directory() }),
(input) => (input) =>
serverSDK() serverSDK.api.integration
.api.integration.get({ integrationID: input.provider, location: location() }) .get({ integrationID: input.provider, location: location() })
.then((result) => result.data), .then((result) => result.data),
) )
const methods = createMemo<ProviderConnectMethod[]>(() => { const methods = createMemo<ProviderConnectMethod[]>(() => {
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
cancelPolling() cancelPolling()
const directory = options.directory() const directory = options.directory()
await queryClient await queryClient
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null)) .refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined) .catch(() => undefined)
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
const poll = async (authorization: Authorization, generation: number) => { const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK() const result = await serverSDK.api.integration.oauth
.api.integration.oauth.status({ .status({
integrationID: options.provider(), integrationID: options.provider(),
attemptID: authorization.attemptID, attemptID: authorization.attemptID,
location: location(), location: location(),
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const result = await serverSDK() const result = await serverSDK.api.integration.oauth
.api.integration.oauth.connect({ .connect({
integrationID: options.provider(), integrationID: options.provider(),
methodID: selected.id, methodID: selected.id,
...(answer ? { answer } : {}), ...(answer ? { answer } : {}),
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
dispatch({ type: "method.reset" }) dispatch({ type: "method.reset" })
} }
const connectKey = async (key: string) => { const connectKey = async (key: string) => {
await serverSDK().api.integration.connect.key({ await serverSDK.api.integration.connect.key({
integrationID: options.provider(), integrationID: options.provider(),
location: location(), location: location(),
key, key,
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
const completeCode = async (code: string) => { const completeCode = async (code: string) => {
const authorization = store.authorization const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid") if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK() const result = await serverSDK.api.integration.oauth
.api.integration.oauth.complete({ .complete({
integrationID: options.provider(), integrationID: options.provider(),
attemptID: authorization.attemptID, attemptID: authorization.attemptID,
location: location(), location: location(),
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServers } from "@/context/servers"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@@ -49,7 +49,7 @@ function useDefaultServer() {
} }
export function useServerActionsController() { export function useServerActionsController() {
const server = useServer() const server = useServers()
const tabs = useTabs() const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
@@ -72,28 +72,16 @@ export function useServerActionsController() {
export type ServerActionsController = ReturnType<typeof useServerActionsController> export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() { export function useServerCollectionController() {
const server = useServer() const server = useServers()
const global = useGlobal() const global = useGlobal()
const settings = useSettings() const settings = useSettings()
const actions = useServerActionsController() const actions = useServerActionsController()
const items = createMemo(() => { const items = createMemo(() => server.list)
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => { const sorted = createMemo(() => {
const raw = items() const raw = items()
const list = raw const list = raw
if (!list.length) return list if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const)) const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => { const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0 if (value?.healthy === true) return 0
@@ -101,8 +89,6 @@ export function useServerCollectionController() {
return 1 return 1
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
@@ -113,7 +99,6 @@ export function useServerCollectionController() {
return { return {
collection: { collection: {
items: sorted, items: sorted,
current,
health: () => global.servers.health, health: () => global.servers.health,
}, },
...actions, ...actions,
@@ -121,21 +106,3 @@ export function useServerCollectionController() {
} }
export type ServerCollectionController = ReturnType<typeof useServerCollectionController> export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management" import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() { function deferred<T>() {
@@ -65,20 +65,17 @@ describe("replaceServerConnection", () => {
const calls: string[] = [] const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, { replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`), removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => { add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`) calls.push(`add:${ServerConnection.key(server)}`)
return server return server
}, },
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`), remove: (key) => calls.push(`remove:${key}`),
}) })
expect(calls).toEqual([ expect(calls).toEqual([
"tabs:https://old.example.com", "tabs:https://old.example.com",
"add:https://new.example.com", "add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com", "remove:https://old.example.com",
]) ])
}) })
@@ -87,10 +84,8 @@ describe("replaceServerConnection", () => {
const removed: ServerConnection.Key[] = [] const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, { replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {}, removeTabs: () => {},
add: () => undefined, add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key), remove: (key) => removed.push(key),
}) })
@@ -1,4 +1,4 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server" import { normalizeServerUrl, ServerConnection } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = { export type ServerFormValues = {
@@ -42,18 +42,13 @@ export function replaceServerConnection(
originalKey: ServerConnection.Key, originalKey: ServerConnection.Key,
next: ServerConnection.Http, next: ServerConnection.Http,
operations: { operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void remove: (key: ServerConnection.Key) => void
}, },
) { ) {
const active = operations.active()
operations.removeTabs(originalKey) operations.removeTabs(originalKey)
const added = operations.add(next) const added = operations.add(next)
if (!added) return if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey) operations.remove(originalKey)
} }
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
@@ -11,7 +11,7 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server" import { type ServerConnection, serverName } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
@@ -39,8 +39,7 @@ function openSessionContext(args: {
}) { }) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context") void args.tabs.open("context").then(() => args.tabs.setActive("context"))
args.tabs.setActive("context")
} }
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
@@ -0,0 +1,136 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: string
placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number
class?: string
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK
const sessionID = props.sessionID
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("selected", undefined)
}
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useServer } from "@/context/server"
export const OPEN_APPS = [ export const OPEN_APPS = [
"vscode", "vscode",
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
app: undefined as OpenApp | undefined, app: undefined as OpenApp | undefined,
}) })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
const current = createMemo( const current = createMemo(
() => () =>
options().find((o) => o.id === prefs.app) ?? options().find((o) => o.id === prefs.app) ??
@@ -1,32 +1,12 @@
import { AppIcon } from "@opencode-ai/ui/app-icon" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button" import { createMemo, Show } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent" import { StatusPopoverV2 } from "../status-popover"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -34,206 +14,15 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar" import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function SessionHeader() { export function SessionHeader() {
const layout = useLayout()
const command = useCommand() const command = useCommand()
const server = useServer()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const sync = useSync() const { view } = useSessionLayout()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
const apps = createMemo(() => {
if (os() === "macos") return MAC_APPS
if (os() === "windows") return WINDOWS_APPS
return LINUX_APPS
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({ const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(), statusVisible: status(),
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
@@ -244,275 +33,16 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
})) }))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = projectDirectory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = projectDirectory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount() const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
})
return ( return (
<> <Show when={rightMount()} keyed>
<Show when={search() && centerMount()} keyed> {(mount) => (
{(mount) => ( <Portal mount={mount}>
<Portal mount={mount}> <SessionHeaderV2Actions state={v2ActionsState()} />
<Button </Portal>
type="button" )}
variant="ghost" </Show>
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Show
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
) )
} }
@@ -527,8 +57,6 @@ type SessionHeaderV2ActionsState = {
} }
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
@@ -1,11 +1,6 @@
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
@@ -14,7 +9,6 @@ import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
import { SettingsListV2 } from "./settings-v2/parts/list" import { SettingsListV2 } from "./settings-v2/parts/list"
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
@@ -202,69 +196,6 @@ function filteredFor(
return out return out
} }
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, input.language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
makeEventListener(document, "keydown", handle, { capture: true })
})
}
export function createKeybindSettingsController( export function createKeybindSettingsController(
input: { input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds"> command: Pick<CommandContext, "catalog" | "options" | "keybinds">
@@ -411,7 +342,7 @@ export function createKeybindSettingsController(
} }
} }
function SettingsKeybindsV2() { export function SettingsKeybinds() {
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const controller = createKeybindSettingsController({ const controller = createKeybindSettingsController({
@@ -532,252 +463,3 @@ function SettingsKeybindsV2View(props: {
</> </>
) )
} }
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
})
const stop = () => {
if (!store.active) return
setStore("active", null)
command.keybinds(true)
}
const start = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
command.keybinds(false)
}
const map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
settings.keybinds.resetAll()
showToast({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
}
const list = createMemo(() => {
language.locale()
return listFor(command, map(), language.t("command.palette"))
})
const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
for (const group of GROUPS) {
const ids = filtered().get(group) ?? []
if (ids.length > 0) return true
}
return false
})
const used = createMemo(() => {
const map = new Map<string, { id: string; title: string }[]>()
const add = (key: string, value: { id: string; title: string }) => {
const list = map.get(key)
if (!list) {
map.set(key, [value])
return
}
list.push(value)
}
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
for (const sig of signatures(palette)) {
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
}
const valueFor = (id: string) => {
const custom = settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = command.options.find((x) => x.id === id)
if (live?.keybind) return live.keybind
const meta = command.catalog.find((x) => x.id === id)
return meta?.keybind
}
for (const id of list().keys()) {
if (id === PALETTE_ID) continue
for (const sig of signatures(valueFor(id))) {
add(sig, { id, title: title(id) })
}
}
return map
})
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
if (store.active) command.keybinds(true)
})
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</Dynamic>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
)
}
@@ -1,9 +1,8 @@
import { type ParentProps, Show } from "solid-js" import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server" import { ServerProvider } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerConnection } from "@/context/servers"
import { ServerSyncProvider } from "@/context/server-sync"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) { export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal() const global = useGlobal()
@@ -20,10 +19,8 @@ export function SettingsServerScope(props: ParentProps<{ directory?: string }>)
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) { export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return ( return (
<ServerSDKProvider server={props.server}> <ServerProvider conn={props.server}>
<ServerSyncProvider server={props.server}> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> </ServerProvider>
</ServerSyncProvider>
</ServerSDKProvider>
) )
} }
@@ -13,7 +13,7 @@ import {
} from "@/components/server/server-management" } from "@/components/server/server-management"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health" import { useCheckServerHealth } from "@/utils/server-health"
import "./settings-v2.css" import "./settings-v2.css"
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
} }
function createFormController(options: { onSelect?: () => void } = {}) { function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServer() const server = useServers()
const tabs = useTabs() const tabs = useTabs()
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
@@ -173,8 +173,7 @@ function createFormController(options: { onSelect?: () => void } = {}) {
}) })
} }
const allServers = () => { const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list return server.list
return [server.current, ...server.list]
} }
const editing = createMemo(() => const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl), allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
@@ -182,10 +181,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
const add = (connection: ServerConnection.Http) => server.add(connection) const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) => const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, { replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key), removeTabs: (key) => tabs.removeServer(key),
add, add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key), remove: (key) => server.remove(key),
}) })
@@ -1,25 +1,25 @@
import { Component, createMemo, createSignal, startTransition } from "solid-js" import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general" import { SettingsGeneral } from "./general"
import { SettingsAppearanceV2 } from "./appearance" import { SettingsAppearanceV2 } from "./appearance"
import { SettingsKeybinds } from "../settings-keybinds" import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsNotificationsV2 } from "./notifications" import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers" import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { SettingsProjectsV2 } from "./projects" import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions" import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker" import { SettingsServerScope } from "../settings-server-picker"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync" import { useGlobal, useServerCtx } from "@/context/global"
import { useGlobal } from "@/context/global" import { ServerConnection, useServers } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogSettings: Component<{ export const DialogSettings: Component<{
@@ -30,22 +30,40 @@ export const DialogSettings: Component<{
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout() const layout = useLayout()
const servers = useServers()
const tabs = useTabs() const tabs = useTabs()
const serverSync = useServerSync()
const global = useGlobal() const global = useGlobal()
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => { const server = createMemo(() => {
const server = global.settings.server.selected() const route = layout.route()
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session":
return servers.list.find((item) => ServerConnection.key(item) === route.server)
case "home":
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
})
const serverCtx = useServerCtx(server)
createEffect(() => {
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
})
const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = layout.route() const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") { if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
return undefined return undefined
}) })
@@ -95,6 +113,10 @@ export const DialogSettings: Component<{
<Icon name="folder" /> <Icon name="folder" />
{language.t("settings.tab.projects")} {language.t("settings.tab.projects")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
</div> </div>
{/* Group 3: Capabilities & Extensions */} {/* Group 3: Capabilities & Extensions */}
@@ -122,7 +144,7 @@ export const DialogSettings: Component<{
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel"> <TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} /> <SettingsGeneral server={server()} sessionID={props.sessionID} />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="appearance" class="settings-v2-panel"> <TabsV2.Content value="appearance" class="settings-v2-panel">
<SettingsAppearanceV2 /> <SettingsAppearanceV2 />
@@ -131,7 +153,7 @@ export const DialogSettings: Component<{
<SettingsNotificationsV2 /> <SettingsNotificationsV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel"> <TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 /> <SettingsServersV2 />
@@ -140,6 +162,9 @@ export const DialogSettings: Component<{
<SettingsProjectsV2 /> <SettingsProjectsV2 />
</TabsV2.Content> </TabsV2.Content>
<SettingsServerScope directory={directory()}> <SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} /> <SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content> </TabsV2.Content>
@@ -23,7 +23,7 @@ export const SettingsExtensionsV2: Component = () => {
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const mcps = createMemo<McpRowItem[]>(() => { const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync().data.config.mcp ?? {} const configMcp = serverSync.data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({ return Object.entries(configMcp).map(([name, config]) => ({
name, name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false, enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
@@ -31,18 +31,16 @@ export const SettingsExtensionsV2: Component = () => {
}) })
const handleMcpToggle = (item: McpRowItem, checked: boolean) => { const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync().data.config.mcp ?? {} const before = serverSync.data.config.mcp ?? {}
const config = before[item.name] const config = before[item.name]
if (typeof config !== "object" || config === null) return if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } } const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync().set("config", "mcp", next) serverSync.set("config", "mcp", next)
void serverSync() void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
.updateConfig({ mcp: next })
.catch(() => serverSync().set("config", "mcp", before))
} }
const plugins = createMemo<PluginRowItem[]>(() => { const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync().data.config.plugin ?? [] const raw = serverSync.data.config.plugin ?? []
return raw.map((item) => { return raw.map((item) => {
const name = typeof item === "string" ? item : item[0] const name = typeof item === "string" ? item : item[0]
return { name } return { name }
@@ -17,17 +17,24 @@ import {
} from "@/context/settings" } from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
import { ServerConnection } from "@/context/servers"
import { useGlobal, useServerCtx } from "@/context/global"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) { export function createPermissionScopeController(
const permission = usePermission() server: Accessor<ServerConnection.Any | undefined>,
const serverSync = useServerSync() sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => { const directory = createMemo(() => {
const s = server()
const id = sessionID() const id = sessionID()
if (!id) return undefined if (!s || !id) return undefined
return serverSync().session.lineage.peek(id)?.session.location.directory return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
}) })
return { return {
@@ -35,21 +42,21 @@ export function createPermissionScopeController(sessionID: Accessor<string | und
const id = sessionID() const id = sessionID()
const dir = directory() const dir = directory()
if (!id || !dir) return false if (!id || !dir) return false
return permission.isAutoAccepting(id, dir) return permission()?.isAutoAccepting(id, dir)
}), }),
enabled: createMemo(() => !!directory()), enabled: createMemo(() => !!directory()),
set: (checked: boolean) => { set: (checked: boolean) => {
const id = sessionID() const id = sessionID()
const dir = directory() const dir = directory()
if (!id || !dir) return if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir) if (checked) return permission()?.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir) permission()?.disableAutoAccept(id, dir)
}, },
} }
} }
export function createShellSettingsController() { export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverSync = useServerSync() const serverCtx = useServerCtx(server)
const [shells] = createResource( const [shells] = createResource(
async () => { async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs. // TODO: Dax is considering the V2 shell discovery and config update APIs.
@@ -58,7 +65,7 @@ export function createShellSettingsController() {
}, },
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
const current = createMemo(() => serverSync().data.config.shell ?? "") const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
return { return {
shells: () => shells.latest, shells: () => shells.latest,
@@ -66,7 +73,7 @@ export function createShellSettingsController() {
select: (value: string) => { select: (value: string) => {
if (value === current()) return if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs. // TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync().updateConfig({ shell: value }) // void serverSync.updateConfig({ shell: value })
}, },
} }
} }
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings" import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
@@ -24,6 +24,7 @@ import {
type SoundSettingsController, type SoundSettingsController,
} from "./general-controllers" } from "./general-controllers"
import "./settings-v2.css" import "./settings-v2.css"
import { ServerConnection } from "@/context/servers"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = { const fontSettings = {
@@ -85,6 +86,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
) )
} }
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage() const language = useLanguage()
const options = createMemo(() => const options = createMemo(() =>
@@ -269,16 +298,20 @@ const LanguageSetting = () => {
) )
} }
export const SettingsGeneralV2: Component<{ export const SettingsGeneral: Component<{
sessionID?: string sessionID?: string
server?: ServerConnection.Any
}> = (props) => { }> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID) const permissionScope = createPermissionScopeController(
const shell = createShellSettingsController() () => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -300,6 +333,7 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} /> <PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} /> <ShellSetting controller={shell} />
@@ -362,18 +396,6 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2> <SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.showSearch.title")} title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")} description={language.t("settings.general.row.showSearch.description")}
@@ -518,7 +540,6 @@ export const SettingsGeneralV2: Component<{
</div> </div>
</div> </div>
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<GeneralSection /> <GeneralSection />
@@ -25,7 +25,7 @@ export const SettingsModelsV2: Component = () => {
const models = useModels() const models = useModels()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [store, setStore] = persisted( const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
) )
@@ -1,7 +1,7 @@
import { Show, createMemo, type Component } from "solid-js" import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/servers"
const allServers = { type: "all" } as const const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers type ServerOption = ServerConnection.Any | typeof allServers
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { getProjectAvatarVariant } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers" import { displayName } from "@/pages/layout/helpers"
import { InlineServerSelect } from "./parts/server-select" import { InlineServerSelect } from "./parts/server-select"
import { DialogEditProjectV2 } from "../dialog-edit-project-v2" import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
@@ -97,7 +97,7 @@ export const SettingsProvidersV2: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => { const isConfigCustom = (providerID: string) => {
const provider = serverSync().data.config.provider?.[providerID] const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -106,13 +106,13 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined const location = props.directory ? { directory: props.directory } : undefined
await serverSdk() await serverSdk.api.integration
.api.integration.get({ integrationID: providerID, location }) .get({ integrationID: providerID, location })
.then(async (integration) => { .then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all( await Promise.all(
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })), credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
) )
showToast({ showToast({
variant: "success", variant: "success",
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/servers"
import { useServerCollectionController } from "../server/server-management-controller" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -692,6 +692,223 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px; width: 480px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
@@ -0,0 +1,502 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/servers"
import type { Project } from "@/types"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
managedWorkspaceDirectories,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import "./settings-v2.css"
type Workspace = {
directory: string
project: Project
}
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
const projects = createMemo(() =>
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
})
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const sessionTime = (session: SessionInfo) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions,
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
return { result, sessions }
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
)
}),
)
}
let inspectionID = 0
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessages={inspectionMessages}
onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-v2-workspaces-path"
>
{workspace.directory}
</span>
</TooltipV2>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
inspectionMessages: (result: WorkspaceDeleteInspection) => string[]
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -5,12 +5,13 @@ import { createEffect, createMemo, createResource, For, type JSXElement, onClean
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServers } from "@/context/servers"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -103,9 +104,6 @@ type ServerStatusItem = {
export function StatusPopoverBody(props: { shown: boolean }) { export function StatusPopoverBody(props: { shown: boolean }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const fail = (err: unknown) => { const fail = (err: unknown) => {
@@ -117,17 +115,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
} }
let dialogRun = 0 let dialogRun = 0
let dialogDead = false
onCleanup(() => { onCleanup(() => {
dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => {
const list = global.servers.list()
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServers } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -14,6 +14,7 @@ import {
hasServiceNeedingAttention, hasServiceNeedingAttention,
serverStatusDotClass, serverStatusDotClass,
} from "./status-popover-indicator" } from "./status-popover-indicator"
import { useServer } from "@/context/server"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -82,16 +83,12 @@ export function StatusPopover() {
} }
export function StatusPopoverV2() { export function StatusPopoverV2() {
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServerSDK() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/servers"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key const remote = "remote" as ServerConnection.Key
@@ -1,9 +1,9 @@
import type { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed" export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = { export type SessionTabsRemovedDetail = {
server?: ServerConnection.Key server: ServerConnection.Key
directory: string directory: string
sessionIDs: string[] sessionIDs: string[]
} }
@@ -19,16 +19,15 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!detail || typeof detail !== "object") return undefined if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined if (!("sessionIDs" in detail)) return undefined
if (!("server" in detail) || typeof detail.server !== "string") return undefined
if (typeof detail.directory !== "string") return undefined if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined if (!Array.isArray(detail.sessionIDs)) return undefined
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string") const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined if (sessionIDs.length === 0) return undefined
return { return {
server: server: ServerConnection.Key.make(detail.server),
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
directory: detail.directory, directory: detail.directory,
sessionIDs, sessionIDs,
} }
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query" import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global" import { useGlobal, useServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName, useServers } from "@/context/servers"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
@@ -46,11 +46,8 @@ export function TabNavItem(props: {
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal() const servers = useServers()
const serverCtx = createMemo(() => { const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => { const project = createMemo(() => {
const session = props.session const session = props.session
if (!session) return if (!session) return
@@ -74,8 +71,8 @@ export function TabNavItem(props: {
}) })
// Only label the server when multiple servers are connected. // Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => { const serverLabel = createMemo(() => {
if (global.servers.list().length <= 1) return if (servers.list.length <= 1) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined return conn ? serverName(conn) : undefined
}) })
@@ -251,6 +248,7 @@ export function TabNavItem(props: {
}} }}
data-slot="tab-title" data-slot="tab-title"
data-titlebar-tab-title data-titlebar-tab-title
dir="auto"
class="min-w-0 flex-1 outline-none leading-4" class="min-w-0 flex-1 outline-none leading-4"
classList={{ classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(), "overflow-hidden text-clip whitespace-nowrap": !editing(),
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers" import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/servers"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, type ServerCtx } from "@/context/global" import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -339,10 +339,9 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => { const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
}) })
if (tab.type === "session") { if (tab.type === "session") {
+39 -219
View File
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal, useServerCtx } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServers } from "@/context/servers"
import { tabKey, useTabs } from "@/context/tabs" import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
@@ -61,15 +61,13 @@ export function useTitlebarRightMount() {
} }
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const server = useServer() const servers = useServers()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns()) const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom") const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
@@ -77,11 +75,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => { const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px` if (mac()) return `${height / zoom()}px`
@@ -97,14 +93,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}) })
const path = () => `${location.pathname}${location.search}${location.hash}` const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => { createEffect(() => {
const current = path() const current = path()
@@ -116,10 +104,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}) })
}) })
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => { const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing ?? false const installing = props.update?.installing ?? false
const version = props.update?.version const version = props.update?.version
@@ -188,7 +172,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
data-tauri-drag-region data-tauri-drag-region
> >
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when>
{(_) => { {(_) => {
const layout = useLayout() const layout = useLayout()
const global = useGlobal() const global = useGlobal()
@@ -200,9 +184,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
() => { () => {
const route = layout.route() const route = layout.route()
if (route.type !== "session") return undefined if (route.type !== "session") return undefined
const conn = global.servers const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), ({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
@@ -245,7 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const s = session() const s = session()
if (!s) return if (!s) return
const sessionId = s.parentID ?? s.id const sessionId = s.parentID ?? s.id
const next = { server: route.server ?? server.key, sessionId } const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@@ -258,53 +240,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const openNewTab = () => { const openNewTab = () => {
const route = layout.route() const route = layout.route()
const activeSession = session() switch (route.type) {
if (route.type === "session" && activeSession) { case "session": {
const sessionTab = { const activeSession = session()
type: "session" as const, if (!activeSession) return
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
}
const activeTab = currentTab() const sessionTab = {
if (activeTab?.type === "draft") { type: "session" as const,
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current() server: route.server,
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) sessionId: activeSession.id,
return }
} const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return return
} }
case "draft": {
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "home": {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
} }
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
} }
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
@@ -430,158 +402,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
) )
}} }}
</Match> </Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !macTrafficLights(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={800}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
>
<IconV2 name="edit" size="small" />
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
</Show>
</div>
</div>
</Match>
</Switch> </Switch>
</header> </header>
) )
+1 -1
View File
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key) const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession( value: createCommentSession(
serverSDK().scope, serverSDK.scope,
decoded.dir, decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id, decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
), ),

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