Compare commits

...

107 Commits

Author SHA1 Message Date
Kit Langton 680ed534cd test(opencode): remove obsolete HTTP API exerciser 2026-07-13 12:49:33 -04:00
Kit Langton cf7d388a83 refactor(opencode): simplify HTTP API exercise DSL 2026-07-13 11:05:46 -04:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
Aiden Cline 64ca9b8d77 refactor(core): replace deferred tool option with codemode (#36560) 2026-07-12 21:21:07 -05:00
Kit Langton a4b91d33d9 fix(tui): align skills dialog copy (#36592) 2026-07-12 22:02:24 -04:00
Kit Langton 397993e898 fix(client): preserve compatible background service (#36583) 2026-07-12 21:32:34 -04:00
Kit Langton c0ed0106b1 fix(tui): show background shell completion (#36534) 2026-07-12 21:01:36 -04:00
opencode-agent[bot] f112a73c06 fix(core): load config across git boundaries (#36577)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 00:05:41 +00:00
Dax Raad 7e9b9cb0fd docs: document TUI config migration 2026-07-12 19:28:26 -04:00
Dax Raad 09a6cecf23 refactor(tui): remove code concealment toggle 2026-07-12 18:12:36 -04:00
opencode-agent[bot] 6e55ddd078 fix(config): load configs across git boundaries (#36568)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-12 22:07:20 +00:00
Dax Raad f8f8cc9546 refactor(tui): simplify config context 2026-07-12 17:12:48 -04:00
Aiden Cline 3c60470269 refactor(codemode): remove tool concurrency cap (#36545) 2026-07-12 16:07:53 -05:00
Dax Raad b2bdab24e6 fix(tui): preserve legacy host compatibility 2026-07-12 17:01:02 -04:00
Dax Raad 3568dd1b99 feat(tui): add managed config interface 2026-07-12 17:01:02 -04:00
opencode-agent[bot] 1e17202413 chore: merge dev into v2 (#36556)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
2026-07-12 15:16:08 -05:00
Aiden Cline 49cb73b348 refactor(codemode): split runtime into focused interpreter modules (#36540) 2026-07-12 12:47:56 -05:00
opencode-agent[bot] 430750e2f8 fix(tui): remove unsupported MCP toggle (#36531)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-12 10:56:24 -05:00
Kit Langton 7913c4a490 refactor(server): share event stream encoding (#36484) 2026-07-12 11:48:52 -04:00
Kit Langton 5414697bd1 fix(tui): show background subagent completion (#36530)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-12 11:43:13 -04:00
Kit Langton e22e3b8f2c fix(cli): keep update preflight through TUI loading (#36475) 2026-07-11 22:19:13 -04:00
Kit Langton 8e657c7db5 fix(core): settle malformed tool input on failure (#36477) 2026-07-11 21:41:57 -04:00
Kit Langton 02e2277057 fix(tui): lower durable event log level (#36479) 2026-07-11 21:37:08 -04:00
Dax Raad ac1ddc3f83 refactor(tui): simplify config and tool display 2026-07-11 19:51:19 -04:00
opencode-agent[bot] 8f904c8e9a fix(cli): load v2 tui config (#36466)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 19:13:29 -04:00
Dax Raad ec07ee56f4 feat(tui): add plugin route api 2026-07-11 18:52:04 -04:00
Dax Raad de86d73d19 feat(tui): add v2 plugin context 2026-07-11 17:56:25 -04:00
Dax Raad 0fa9e5039e refactor(tui): reduce legacy sync usage 2026-07-11 17:05:23 -04:00
Dax Raad c073387723 refactor(tui): use default client export 2026-07-11 16:44:55 -04:00
Dax Raad 1956497f42 fix(client): loosen opaque promise outputs 2026-07-11 16:38:02 -04:00
Dax Raad 2a7e32c416 refactor(tui): remove legacy sdk surfaces 2026-07-11 16:38:02 -04:00
Kit Langton 56a7c06a80 fix(cli): hand update completion directly to the TUI (#36455) 2026-07-11 15:42:31 -04:00
Aiden Cline 75e8fd4da2 test(codemode): drop leftover $codemode references (#36451) 2026-07-11 14:15:11 -05:00
opencode-agent[bot] b9f39dd751 fix(core): disable unused fff content caches (#36452)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 15:03:54 -04:00
Aiden Cline 6eeeb4bfcf feat(codemode): make search a global built-in and rewrite README (#36450) 2026-07-11 13:43:33 -05:00
Kit Langton 1ccaca826e feat(cli): update preflight UI while replacing the background service (#36448) 2026-07-11 14:43:16 -04:00
Aiden Cline 04f9b15178 fix(core): include child session id in background subagent start text (#36447) 2026-07-11 13:34:30 -05:00
Kit Langton 66b9cc7931 fix(client): accept larger SSE events (#36442) 2026-07-11 14:15:53 -04:00
Kit Langton e3f7637eb2 feat(tui): add semantic file path truncation (#36352) 2026-07-11 13:57:29 -04:00
Kit Langton 5945a8d429 fix(tui): restore queued compaction indicator (#36440) 2026-07-11 13:54:23 -04:00
Kit Langton c7ceccf869 fix(core): skip fff for aggregate locations (#36437) 2026-07-11 13:47:41 -04:00
Kit Langton 00ab94c44f fix(tui): stabilize compaction completion (#36435) 2026-07-11 13:06:14 -04:00
Aiden Cline fe09a2e9b7 feat(codemode): support Promise.any and new Promise construction (#36339) 2026-07-10 20:46:13 -05:00
Dax 59202356fe fix(tui): fork messages with agent attachments (#36338) 2026-07-10 21:23:46 -04:00
Dax 637282f3b9 fix(tui): make composer close action discoverable (#36337) 2026-07-10 21:19:57 -04:00
opencode-agent[bot] d1550cb599 feat(core): port GitHub Copilot OAuth (#36336)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-10 21:11:12 -04:00
Dax Raad ed6c117184 fix(tui): retain leader subagent shortcut 2026-07-10 21:01:55 -04:00
Dax Raad 6ce62bd84a feat(tui): open subagents with down 2026-07-10 21:01:04 -04:00
Dax Raad a4a948316b fix(core): skip unchanged plugin activation 2026-07-10 20:49:16 -04:00
Dax Raad aca3b7813d fix(core): publish skill source updates 2026-07-10 20:42:44 -04:00
Dax Raad 0eaa75ec0f fix(tui): total root session family cost 2026-07-10 19:51:10 -04:00
Kit Langton 6b3c4f5839 refactor(core): combine git discovery queries (#36321) 2026-07-10 19:41:48 -04:00
Aiden Cline 8e76adb08f feat(codemode): support promise chaining with .then/.catch/.finally (#36304) 2026-07-10 18:22:04 -05:00
Kit Langton 0bb24a46c1 fix(core): defer snapshot repository discovery (#36290) 2026-07-10 17:34:40 -04:00
James Long d3f4695af3 docs: add opencode-drive skill (#36319) 2026-07-10 17:30:41 -04:00
Dax Raad 5b2715d24f refactor(plugin): simplify promise adapter imports 2026-07-10 17:18:46 -04:00
Dax Raad 20e37e7122 fix(plugin): adapt promise host wire values 2026-07-10 17:18:03 -04:00
Dax Raad b87bb4486f fix(client): generate standalone promise DTOs 2026-07-10 17:06:45 -04:00
Dax Raad feaec7a6be fix overloaded models issue 2026-07-10 16:56:37 -04:00
Dax Raad 1c67004999 fix(cli): show background server startup status 2026-07-10 16:14:19 -04:00
opencode-agent[bot] 43ecf3ff1b chore: merge dev into v2 (#36312)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-10 15:11:29 -05:00
Dax Raad 9028c2d8f8 docs: clarify beta binary and MCP route 2026-07-10 15:54:30 -04:00
James Long be18f22842 feat(simulation): record viewport resizes (#36306) 2026-07-10 15:08:43 -04:00
Dax Raad 6216dbae15 docs: simplify intro page 2026-07-10 13:39:46 -04:00
Kit Langton 96a9731947 feat(core): replace instruction checkpoints with value-delta sync (#36254) 2026-07-10 13:26:25 -04:00
Dax Raad 768a69bbbd sync 2026-07-10 12:30:49 -04:00
Kit Langton b7b941c354 fix(tui): unify pending tool styling (#36278) 2026-07-10 12:29:09 -04:00
Kit Langton cf5e0adf02 fix(core): simplify compaction semantics (#36267) 2026-07-10 11:36:29 -04:00
Kit Langton 41d0a9f010 fix(tui): hide editor context from transcript (#36264) 2026-07-10 11:33:04 -04:00
Dax Raad accaa792d8 docs: clarify opencode skill references 2026-07-10 11:24:03 -04:00
Dax Raad 76d7a11585 docs: expand opencode skill guidance 2026-07-10 11:23:20 -04:00
Dax Raad c867e66ab5 feat(plugin): add promise plugin cleanup 2026-07-10 11:23:20 -04:00
Kit Langton 278c510549 refactor(core): simplify session runner bookkeeping (#36200) 2026-07-10 09:46:34 -04:00
Dax Raad 0d6ccd2a50 docs: add build overview 2026-07-10 02:51:30 -04:00
Aiden Cline 39cceeb143 fix(codemode): return promises from combinators (#35782) 2026-07-10 00:33:21 -05:00
Aiden Cline a6449cb45c refactor(form): model links as fields (#36129) 2026-07-10 00:22:01 -05:00
Dax Raad 6a85f0d3db feat(client): add root promise entrypoint 2026-07-10 01:09:06 -04:00
Dax Raad fbb0fdf88a fix(plugin): select plugins by id 2026-07-10 00:44:43 -04:00
Dax Raad 2a4b298108 refactor(plugin): include name in promise tools 2026-07-10 00:22:45 -04:00
Dax Raad efeff63749 refactor(plugin): simplify promise tool declarations 2026-07-10 00:18:13 -04:00
Kit Langton d54038b9d2 docs(v2): consolidate specifications (#36186) 2026-07-09 22:47:37 -04:00
Dax Raad 8f04a09d1a refactor(plugin): remove protocol dependency 2026-07-09 22:25:38 -04:00
Dax Raad 761f37370f fix(plugin): restore package publishing 2026-07-09 22:18:58 -04:00
Kit Langton b452368b3b refactor(core): simplify tool admission flow (#36180) 2026-07-09 22:01:31 -04:00
Kit Langton 3785eddfa0 fix(core): preserve admitted tool generations (#36177) 2026-07-09 21:16:00 -04:00
Dax Raad 4a006b1210 refactor(core): derive config watches from entries 2026-07-09 21:12:15 -04:00
Aiden Cline 2db7ccb453 fix(core): restore resilient compaction (#36163) 2026-07-09 19:13:11 -05:00
Aiden Cline 629b304c48 fix(core): continue after configured permission denial (#36155) 2026-07-09 17:35:25 -05:00
Dax Raad 1004fbb2f5 docs: expand OpenCode 2 beta guides 2026-07-09 18:01:15 -04:00
opencode-agent[bot] a7746379d5 chore: merge dev into v2 (#36144)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-09 16:32:44 -05:00
Kit Langton a72992e00f feat(core): session.pending.list API with pending-only session_pending storage (#36126) 2026-07-09 17:25:40 -04:00
James Long 99156c10e6 feat(cli): export frontend logs over OTLP (#36152) 2026-07-09 17:19:22 -04:00
Dax Raad f6f0a5c10f docs: expand user guides 2026-07-09 16:57:13 -04:00
Dax Raad e2eed76101 refactor(tui): migrate v2 calls to new client 2026-07-09 16:57:13 -04:00
Kit Langton 9ff7ef3fb0 fix(tui): handle session search fetch failures without crashing (#36149) 2026-07-09 16:38:58 -04:00
Dax Raad 8fe78c8f8d fix(core): restore plugins after failed activation 2026-07-09 16:28:22 -04:00
Harsh Mathur bd0dffd781 fix(llm): preserve nested OpenAI stream errors (#36130)
Co-authored-by: Harsh Mathur <jeevesforharsh@gmail.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-09 14:40:28 -05:00
Dax Raad 0ac8f91ed6 fix(core): restore catalog status schema export 2026-07-09 14:20:36 -04:00
Dax Raad 4c80c23e11 fix(tui): reconcile refreshed messages 2026-07-09 14:12:02 -04:00
Dax Raad 33c0cc2bb6 fix: synchronize remote data refreshes 2026-07-09 13:56:28 -04:00
Dax Raad cc686ab8f6 fix(cli): unify server resolution 2026-07-09 13:28:01 -04:00
Kit Langton 6524dfc818 feat(core): resume suspended sessions after service restart (#36105) 2026-07-09 13:19:15 -04:00
Dax Raad c91604d519 chore(opencode): suppress dead v1 model types 2026-07-09 13:15:20 -04:00
Dax Raad a2718177be test(core): align models dev snapshot fixtures 2026-07-09 13:15:20 -04:00
Dax Raad 6fb526741b refactor(tui): rename link concept to pair 2026-07-09 13:15:20 -04:00
Dax Raad 85fdb3bde6 refactor(core): optimize location startup 2026-07-09 13:15:20 -04:00
James Long b642f9c7bc feat(tui): log event stream connections (#35973) 2026-07-09 13:13:34 -04:00
626 changed files with 198519 additions and 134258 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/client": patch
---
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
+4
View File
@@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@@ -7,11 +8,14 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
+254
View File
@@ -0,0 +1,254 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+7 -6
View File
@@ -27,6 +27,7 @@ Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
@@ -154,14 +155,14 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` 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.
- The durable `Instructions.Applied` record is what the model was last told, per instruction source. Reconciliation narrates drift through `session.instructions.updated` and never rewrites the baseline; only completed compaction rebaselines, while Session movement or committed revert resets the `InstructionCheckpoint`. Unavailable sources keep the model's prior belief, blocking only a Session's first instruction baseline.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
+37 -37
View File
@@ -13,11 +13,11 @@ The opaque algebra of independently refreshable typed instruction sources that r
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a **Step** after applying the active compaction and **InstructionCheckpoint** baseline cutoffs.
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
_Avoid_: Session Context
**Instruction Source**:
One independently observed typed value within **Instructions**, represented by a stable namespaced key, JSON codec, loader, pure baseline/update renderers, and an optional removal renderer.
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**InstructionEntry**:
@@ -26,22 +26,25 @@ One API-managed, durable, per-Session instruction value. Its slash-free client k
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**InstructionCheckpoint**:
The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps.
**Instruction State**:
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
**Instruction Update**:
A durable chronological System message published as `session.instructions.updated` that tells the model the newly effective state of one or more changed **Instruction Sources**.
_Avoid_: System notification, raw text diff
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
_Avoid_: Correction, stored prose, raw text diff
**Instruction Baseline**:
The exact joined instruction text stored by **InstructionCheckpoint** and sent as immutable provider-cache prefix state until completed compaction rebaselines it or Session movement or committed revert resets it.
**Initial Instructions**:
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Applied Instructions**:
The overwriteable model-hidden `Instructions.Applied` record in **InstructionCheckpoint**, containing what the model was last told per **Instruction Source**.
**Instruction Epoch**:
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
**Instruction Values**:
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
**Unavailable Instruction Source**:
An expected temporary inability to observe an **Instruction Source** value; the runtime retains its prior effective state and emits no update, while an unavailable source blocks creation of the first complete **Instruction Baseline**.
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
@@ -53,7 +56,7 @@ A durable user input accepted into the Session inbox but not yet included in **S
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Step**:
One logical LLM call spanning pre-flight instruction checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
@@ -108,18 +111,16 @@ _Avoid_: Response envelope
## Relationships
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, the **Instruction Baseline**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** contains projected conversational messages and admitted **Instruction Updates**; the active **Instruction Baseline** remains separate provider-request state.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** loader returns one coherent typed value or explicitly reports unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its codec compares and stores the value, while pure renderers produce baseline, update, and optional removal text.
- `Instructions.initialize(...)` observes composed **Instructions** once and produces a complete **Instruction Baseline** with **Applied Instructions**.
- `Instructions.reconcile(...)` observes composed **Instructions** once and returns either unchanged or one combined chronological update. It never rewrites the baseline.
- `Instructions.rebaseline(...)` renders a fresh baseline after completed compaction, recalling previously applied values for sources that are temporarily unavailable.
- A changed **Instruction Source** may contribute text to one **Instruction Update** containing the newly effective state.
- An **Instruction Update** persists the exact combined rendered text sent to the model through `session.instructions.updated`.
- **Applied Instructions** advances atomically with the corresponding durable **Instruction Update**.
- **Applied Instructions** stores one codec-encoded JSON value and, for removable sources, a pre-rendered removal message per stable **Instruction Source** key.
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
@@ -130,28 +131,27 @@ _Avoid_: Response envelope
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** renders the latest complete **Instruction Baseline** and creates its **InstructionCheckpoint** without emitting a redundant **Instruction Update**; an unavailable initial source blocks the Step instead of persisting an incomplete baseline.
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history.
- A newly composed **Instruction Source** absent from **Applied Instructions** emits its baseline rendering once at the next **Safe Step Boundary**.
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session resets its **InstructionCheckpoint**, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint.
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- An **Instruction Baseline** is stored durably and reused verbatim across process restarts until rebaseline or reset.
- An **Instruction Baseline** durably preserves the exact joined text used for its part of the active provider-cache prefix.
- A model/provider switch preserves the current **InstructionCheckpoint** and chronological conversation history; the new selection applies to the next **Step**.
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
@@ -182,12 +182,12 @@ _Avoid_: Response envelope
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
@@ -195,10 +195,10 @@ _Avoid_: Response envelope
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose system text, **Instruction Baseline**, tools, and step-local additions remain separate.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
@@ -209,13 +209,13 @@ _Avoid_: Response envelope
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+68 -36
View File
@@ -30,8 +30,9 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -73,6 +74,7 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "catalog:",
},
"devDependencies": {
@@ -94,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.15",
"version": "1.17.18",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -113,6 +115,7 @@
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"opentui-spinner": "catalog:",
@@ -153,7 +156,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -167,7 +170,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -203,7 +206,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -230,7 +233,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -252,7 +255,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -276,7 +279,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -296,7 +299,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.15",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -320,7 +323,7 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -392,7 +395,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -452,7 +455,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -466,7 +469,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"effect": "catalog:",
},
@@ -478,7 +481,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -510,7 +513,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -526,7 +529,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
},
@@ -558,7 +561,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -577,7 +580,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.15",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -603,7 +606,7 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
@@ -710,12 +713,10 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -790,6 +791,8 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -797,7 +800,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -812,7 +815,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -829,7 +832,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -890,7 +893,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -903,7 +906,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -936,7 +939,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -955,7 +958,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -996,7 +999,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@@ -1027,7 +1030,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1078,7 +1081,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.15",
"version": "1.17.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1121,6 +1124,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@@ -1130,7 +1134,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
@@ -1143,6 +1146,7 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
@@ -1268,7 +1272,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
@@ -1552,6 +1556,10 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -2884,6 +2892,8 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
@@ -5600,11 +5610,15 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
"solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
"solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="],
"solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
@@ -5612,6 +5626,8 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
@@ -6240,7 +6256,11 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
"@alcalzone/ansi-tokenize/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -6476,6 +6496,8 @@
"@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -6774,6 +6796,8 @@
"@slack/web-api/p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
"@solid-primitives/memo/@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
@@ -6860,6 +6884,8 @@
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -7218,6 +7244,8 @@
"socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
@@ -7364,6 +7392,8 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -7918,6 +7948,8 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+4
View File
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
// so a regression cannot silently burn money. Stats sync full passes scan
// ~250 GB as of 2026-07.
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
+3 -1
View File
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
memory: "2 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
}
}
+2 -1
View File
@@ -52,6 +52,7 @@
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
@@ -153,7 +154,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -0,0 +1,155 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}
@@ -0,0 +1,152 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -9,12 +9,19 @@ const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
@@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? branchDiffs
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
}),
)
})
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
@@ -96,7 +114,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
@@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
@@ -201,12 +243,21 @@ function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function fileDiff(file: string, additions: number) {
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
@@ -0,0 +1,228 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}
+10
View File
@@ -21,6 +21,7 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
}
@@ -65,6 +66,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.15",
"version": "1.17.18",
"description": "",
"type": "module",
"exports": {
@@ -47,6 +47,7 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -88,6 +89,7 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "catalog:"
}
}
+10 -1
View File
@@ -12,6 +12,7 @@ import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -174,6 +175,7 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>)
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -182,7 +184,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -20,14 +20,11 @@ import {
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
const DialogCommandPaletteV2 = lazy(() =>
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
)
type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
@@ -0,0 +1,173 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (!listEl) return
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
if (buttons.length === 0) return
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next =
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
buttons[(next + buttons.length) % buttons.length]?.focus()
e.preventDefault()
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<DialogBody class="min-h-0 flex-1 gap-0">
<ScrollView class="min-h-0 flex-1 w-full">
<div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
</div>
<For each={model.list()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
</button>
)}
</For>
<button
type="button"
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="dot-grid" size="small" />
</span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
</button>
</div>
</div>
</div>
</div>
</ScrollView>
</DialogBody>
</DialogV2>
)
}
@@ -474,7 +474,7 @@ export function ModelSelectorPopoverV2(props: {
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
@@ -201,6 +201,7 @@ export default function FileTree(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
onFileDoubleClick={props.onFileDoubleClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
onDblClick={() => props.onFileDoubleClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
+134 -44
View File
@@ -1,54 +1,144 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import { Persist, persisted } from "@/utils/persist"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
const platform = usePlatform()
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={!state.dismissed}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={() => setState("dismissed", true)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
setState("dismissed", true)
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
</p>
</div>
</button>
</div>
</Show>
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
June 16
</p>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
</p>
</div>
</DrawerContent>
</Drawer>
)
}
+161 -52
View File
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -43,6 +45,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@@ -520,7 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
requestAnimationFrame(() => editorRef?.focus())
}
@@ -554,7 +558,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
])
const closePopover = () => setStore("popover", null)
const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
const resetHistoryNavigation = (force = false) => {
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
@@ -800,17 +804,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
const menu = store.slashMenu
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
if (menu) {
editorRef.focus()
setCursorPosition(editorRef, 0)
addPart({ type: "text", content: text, start: 0, end: text.length })
focusEditorEnd()
return
}
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
if (menu) {
command.trigger(cmd.id, "slash")
return
}
clearEditor()
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
@@ -1072,10 +1089,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (atMatch) {
atOnInput(atMatch[1])
setStore("popover", "at")
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
} else {
closePopover()
}
@@ -1171,6 +1188,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const openCommands = () => {
const populated = prompt.dirty() || commentCount() > 0
requestAnimationFrame(() => {
if (!populated) {
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
slashOnInput("")
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
return
}
slashOnInput("")
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
})
}
const openContext = () => {
requestAnimationFrame(() => {
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
atOnInput("")
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
})
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
@@ -1199,7 +1238,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
setStore("mode", "normal")
setStore("popover", null)
closePopover()
setStore("historyIndex", -1)
setStore("savedPrompt", null)
prompt.set(edit.prompt, promptLength(edit.prompt))
@@ -1286,13 +1325,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1325,7 +1368,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
setStore("mode", "shell")
setStore("popover", null)
closePopover()
event.preventDefault()
return
}
@@ -1460,6 +1503,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closePopover()
requestAnimationFrame(() => editorRef.focus())
event.preventDefault()
return
}
if (event.key === "Tab") {
selectPopoverActive()
event.preventDefault()
return
}
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
if (!nav && !ctrlNav) return
slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
event.preventDefault()
}
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading
@@ -1488,9 +1554,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
})
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}))
@@ -1527,6 +1595,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashActive={slashActive() ?? undefined}
setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect}
slashMenu={store.slashMenu}
slashMenuQuery={store.slashMenuQuery}
onSlashMenuInput={(value) => {
setStore("slashMenuQuery", value)
slashOnInput(value)
}}
onSlashMenuKeyDown={handleSlashMenuKeyDown}
commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
@@ -1625,23 +1700,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placement="top"
value={
<>
{language.t("prompt.action.attachFile")}
{language.t("prompt.menu.addImagesAndFiles")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButtonV2}
data-action="prompt-attach"
type="button"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={openCommands} shortcut="/">
{language.t("prompt.menu.commands")}
</MenuV2.Item>
<MenuV2.Item onSelect={openContext} shortcut="@">
{language.t("prompt.menu.context")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
{language.t("prompt.menu.shellCommand")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
@@ -1682,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="max-w-[160px] justify-start capitalize"
style={control()}
>
<span class="truncate">
<span class="truncate leading-5">
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
@@ -1969,11 +2063,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => (
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
})
dialog.show(() => (
<DialogSelectModelUnpaid model={props.controls.model.selection} />
))
}}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
@@ -2140,30 +2232,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
</>
}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
<Show
when={props.state.newLayoutDesigns}
fallback={
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<ModelControlContent state={props.state} v2 />
</ButtonV2>
</Show>
</TooltipV2>
}
>
@@ -41,6 +41,10 @@ type PromptPopoverProps = {
slashActive?: string
setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void
slashMenu: boolean
slashMenuQuery: string
onSlashMenuInput: (value: string) => void
onSlashMenuKeyDown: (event: KeyboardEvent) => void
commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
</Show>
</Match>
<Match when={props.popover === "slash"}>
<Show when={props.slashMenu}>
<div class="px-2 py-1">
<input
ref={(el) => requestAnimationFrame(() => el.focus())}
value={props.slashMenuQuery}
onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
onKeyDown={props.onSlashMenuKeyDown}
onMouseDown={(event) => event.stopPropagation()}
aria-label={props.t("prompt.menu.commands")}
placeholder="/"
class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
/>
</div>
</Show>
<Show
when={props.slashFlat.length > 0}
fallback={
@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -33,6 +34,10 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
context: {
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
})
})
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -296,9 +297,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const currentModel = local.model.current()
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
const variant = local.model.variant.current()
const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -372,13 +374,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
}
}
if (!session) {
@@ -4,6 +4,8 @@ import type { PromptHistoryEntry } from "./history"
export type PromptInputTransientState = {
popover: "at" | "slash" | null
slashMenu: boolean
slashMenuQuery: string
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
@@ -16,6 +18,8 @@ export type PromptInputTransientState = {
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
setStore({
popover: null,
slashMenu: false,
slashMenuQuery: "",
historyIndex: -1,
savedPrompt: null,
draggingType: null,
@@ -28,6 +32,8 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
const [store, setStore] = createStore<PromptInputTransientState>({
popover: null,
slashMenu: false,
slashMenuQuery: "",
historyIndex: -1,
savedPrompt: null,
placeholder,
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const settings = useSettings()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -127,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,6 +21,7 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -135,7 +135,6 @@ export function SessionContextTab() {
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -175,11 +176,7 @@ export function SessionHeader() {
return LINUX_APPS
})
const fileManager = createMemo(() => {
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5 min-w-0">
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
</span>
</Show>
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
{getFilename(props.path)}
</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
onMouseDown={(e) => {
// Switch on mousedown to shave the press-release delay off tab switches.
if (e.button !== 0) return
if (store.editing) return
focus()
}}
onClick={(e) => {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (e.detail > 0) return
focus()
}}
closeButton={
<IconButton
icon="close-small"
@@ -4,7 +4,7 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog() {
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
@@ -19,7 +19,7 @@ export function useSettingsDialog() {
const sessionID = params.id
void import("@/components/settings-v2").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
})
}
}
@@ -20,6 +20,11 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-v2-panel::-webkit-scrollbar {
@@ -181,6 +186,7 @@
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
}
.settings-v2-nav-footer > span {
+19 -2
View File
@@ -1,7 +1,8 @@
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
import { showToast } from "@/utils/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
let current = tokens[key]
for (let i = 0; i < 8 && current; i++) {
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
if (!match) {
const hex = current.trim()
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
return hex
}
current = tokens[match[1]]
}
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const background = settings.general.newLayoutDesigns()
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
fallback.background)
: (resolved["background-stronger"] ?? fallback.background)
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
+10 -2
View File
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return
}
const activeTab = currentTab()
if (activeTab?.type === "draft") {
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Taken from https://www.solid-ui.com/docs/components/drawer
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
*/
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
import DrawerPrimitive from "@corvu/drawer"
const Drawer = DrawerPrimitive
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
const drawerContext = DrawerPrimitive.useContext()
const overlayStyle = () => {
const state = drawerContext.transitionState()
if (state === "opening" || state === "closing") return undefined
const open = drawerContext.openPercentage()
return {
opacity: open,
"backdrop-filter": `blur(${4 * open}px)`,
}
}
return (
<DrawerPrimitive.Overlay
class={props.class}
classList={{
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
}}
style={overlayStyle()}
{...rest}
/>
)
}
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
class?: string
children?: JSX.Element
}
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
{props.children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
}
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
}
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
return (
<DrawerPrimitive.Label
class={props.class}
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
{...rest}
/>
)
}
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
class?: string
}
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
return (
<DrawerPrimitive.Description
class={props.class}
classList={{
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
}}
{...rest}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+8 -4
View File
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
const search = (query: string, dirs: "true" | "false") =>
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs })
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
(error) => {
if (options?.signal?.aborted) throw error
return []
},
)
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) => search(query, "false"),
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
search(query, "false", options),
searchFilesAndDirectories: (query: string) => search(query, "true"),
}
},
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import {
SESSION_OPEN_FILE_TAB,
closeSessionTab,
openSessionTab,
previewSessionTab,
type SessionTabState,
} from "./layout-tabs"
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
tabs: { all, active },
preview,
})
describe("previewSessionTab", () => {
test("appends the Open File placeholder", () => {
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
)
})
test("replaces the current preview in place", () => {
expect(
previewSessionTab(
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://a.ts",
),
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
})
test("activates a durable tab without duplicating it", () => {
expect(
previewSessionTab(
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
})
test("replaces a restored Open File placeholder", () => {
expect(
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
})
})
describe("openSessionTab", () => {
test("pins the current preview", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
state(["file://a.ts"], "file://a.ts"),
)
})
test("replaces a preview with a directly opened file", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
state(["file://b.ts"], "file://b.ts"),
)
})
test("keeps the preview when switching to Review", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
state(["file://a.ts"], "review", "file://a.ts"),
)
})
test("replaces a restored Open File placeholder with a direct open", () => {
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
)
})
})
describe("closeSessionTab", () => {
test("clears preview metadata and selects the left neighbor", () => {
expect(
closeSessionTab(
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
})
})
+103
View File
@@ -0,0 +1,103 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export type SessionTabs = {
active?: string
all: string[]
}
export type SessionTabState = {
tabs: SessionTabs
preview?: string
}
const sessionTabPreview = (current: SessionTabState) =>
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
preview: tab,
}
}
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
if (tab === "review") {
return {
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
preview,
}
}
if (tab === "context") {
return {
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
preview,
}
}
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab } }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
}
}
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
if (tab === "review") {
if (current.tabs.active !== tab) return current
return {
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
preview: current.preview,
}
}
const all = current.tabs.all.filter((item) => item !== tab)
const preview = current.preview === tab ? undefined : current.preview
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
const index = current.tabs.all.indexOf(tab)
return {
tabs: {
all,
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
},
preview,
}
}
+82 -39
View File
@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -55,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
}
@@ -78,6 +76,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@@ -86,14 +85,6 @@ export type LayoutRoute =
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
@@ -307,6 +298,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
sessionTabPreview: {} as Record<string, string | undefined>,
})
const MAX_SESSION_KEYS = 50
@@ -365,6 +357,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
scroll.drop(drop)
dropSessionState(drop)
setEphemeral(
"sessionTabPreview",
produce((draft) => {
for (const key of drop) delete draft[key]
}),
)
for (const key of drop) {
usage.used.delete(key)
@@ -790,6 +788,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -861,6 +867,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
@@ -932,10 +964,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
batch(() => {
setStore("sessionTabs", session, next.tabs)
setEphemeral("sessionTabPreview", session, next.preview)
})
}
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
@@ -948,40 +987,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
batch(() => {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
const preview = ephemeral.sessionTabPreview[session]
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
})
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
apply(
session,
openSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
previewTab(tab: string) {
const session = key()
apply(
session,
previewSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
})
apply(
session,
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
)
},
move(tab: string, to: number) {
const session = key()
+6 -3
View File
@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
reset() {
setStore({ draft: undefined, promoting: undefined })
},
promote(dir: string, session: string) {
const next = clone(snapshot())
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createPromptState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createPromptState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
})
+265
View File
@@ -0,0 +1,265 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
type PromptStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function promptStore(initial?: InitialPrompt): PromptStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => setStore("model", model),
},
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
+49 -271
View File
@@ -1,186 +1,49 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import {
createPromptReady,
createPromptSession,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export {
createPromptReady,
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isPromptEqual,
} from "./prompt-state"
export type {
AgentPart,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
PromptScope,
PromptSession,
TextPart,
} from "./prompt-state"
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
@@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
)
}
function scopeKey(scope: Scope) {
function scopeKey(scope: PromptScope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
@@ -199,91 +62,6 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
@@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
for (const entry of cache.values()) entry.dispose()
cache.clear()
}
@@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = () =>
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => {
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const key = scopeKey(scope)
const existing = cache.get(key)
@@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: Scope) => pick(scope).capture(),
capture: (scope?: PromptScope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
model: {
current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model),
},
context: {
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
@@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
}
},
})
+3
View File
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
}
return {
get<T>(key: string, name: string) {
return entries.get(key)?.get(name)?.value as T | undefined
},
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
+2
View File
@@ -22,6 +22,8 @@ describe("tab memory", () => {
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
expect(memory.get("missing", "prompt")).toBeUndefined()
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
+9 -3
View File
@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
export type SessionTab = {
type: "session"
@@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
tabs.push(tab)
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
navigate(draftHref(draftID))
})
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
@@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
}
return { ...actions, store, info, ready, recentReady }
+10
View File
@@ -12,6 +12,16 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "*.png" {
const src: string
export default src
}
declare module "*.mp4" {
const src: string
export default src
}
export declare module "solid-js" {
namespace JSX {
interface Directives {
+6
View File
@@ -267,6 +267,11 @@ export const dict = {
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
"prompt.context.removeFile": "إزالة الملف من السياق",
"prompt.action.attachFile": "إرفاق ملف",
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
"prompt.menu.imagesAndFiles": "الصور والملفات",
"prompt.menu.commands": "الأوامر",
"prompt.menu.context": "السياق",
"prompt.menu.shellCommand": "أمر shell",
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
@@ -548,6 +553,7 @@ export const dict = {
"home.sessions.group.today": "اليوم",
"home.sessions.group.yesterday": "أمس",
"home.sessions.group.older": "الأقدم",
"home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
+7
View File
@@ -267,6 +267,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
"prompt.context.removeFile": "Remover arquivo do contexto",
"prompt.action.attachFile": "Anexar arquivo",
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
"prompt.menu.imagesAndFiles": "Imagens e arquivos",
"prompt.menu.commands": "Comandos",
"prompt.menu.context": "Contexto",
"prompt.menu.shellCommand": "Comando shell",
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
@@ -552,6 +557,8 @@ export const dict = {
"home.sessions.group.today": "Hoje",
"home.sessions.group.yesterday": "Ontem",
"home.sessions.group.older": "Mais antigas",
"home.providerTip":
"Conecte-se a mais de 75 provedores para usar outros modelos, incluindo Claude, GPT, Gemini e muito mais",
"session.tab.session": "Sessão",
"session.tab.review": "Revisão",
"session.tab.context": "Contexto",
+7
View File
@@ -287,6 +287,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta",
"prompt.context.removeFile": "Ukloni datoteku iz konteksta",
"prompt.action.attachFile": "Priloži datoteku",
"prompt.menu.addImagesAndFiles": "Dodaj datoteke i više",
"prompt.menu.imagesAndFiles": "Slike i datoteke",
"prompt.menu.commands": "Komande",
"prompt.menu.context": "Kontekst",
"prompt.menu.shellCommand": "Shell naredba",
"prompt.attachment.remove": "Ukloni prilog",
"prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi",
@@ -603,6 +608,8 @@ export const dict = {
"home.sessions.group.today": "Danas",
"home.sessions.group.yesterday": "Jučer",
"home.sessions.group.older": "Starije",
"home.providerTip":
"Povežite se s više od 75 pružalaca usluga kako biste koristili druge modele, uključujući Claude, GPT, Gemini i druge",
"session.tab.session": "Sesija",
"session.tab.review": "Pregled",
+7
View File
@@ -285,6 +285,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
"prompt.context.removeFile": "Fjern fil fra kontekst",
"prompt.action.attachFile": "Vedhæft fil",
"prompt.menu.addImagesAndFiles": "Tilføj filer og mere",
"prompt.menu.imagesAndFiles": "Billeder og filer",
"prompt.menu.commands": "Kommandoer",
"prompt.menu.context": "Kontekst",
"prompt.menu.shellCommand": "Shell-kommando",
"prompt.attachment.remove": "Fjern vedhæftning",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
@@ -600,6 +605,8 @@ export const dict = {
"home.sessions.group.today": "I dag",
"home.sessions.group.yesterday": "I går",
"home.sessions.group.older": "Ældre",
"home.providerTip":
"Opret forbindelse til mere end 75 udbydere for at bruge andre modeller, herunder Claude, GPT, Gemini og flere",
"session.tab.session": "Session",
"session.tab.review": "Gennemgang",
+7
View File
@@ -272,6 +272,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
"prompt.context.removeFile": "Datei aus dem Kontext entfernen",
"prompt.action.attachFile": "Datei anhängen",
"prompt.menu.addImagesAndFiles": "Dateien und mehr hinzufügen",
"prompt.menu.imagesAndFiles": "Bilder und Dateien",
"prompt.menu.commands": "Befehle",
"prompt.menu.context": "Kontext",
"prompt.menu.shellCommand": "Shell-Befehl",
"prompt.attachment.remove": "Anhang entfernen",
"prompt.action.send": "Senden",
"prompt.action.stop": "Stopp",
@@ -560,6 +565,8 @@ export const dict = {
"home.sessions.group.today": "Heute",
"home.sessions.group.yesterday": "Gestern",
"home.sessions.group.older": "Älter",
"home.providerTip":
"Verbinde dich mit über 75 Anbietern, um weitere Modelle wie Claude, GPT, Gemini und andere zu nutzen",
"session.tab.session": "Sitzung",
"session.tab.review": "Überprüfung",
"session.tab.context": "Kontext",
+13 -4
View File
@@ -65,8 +65,8 @@ export const dict = {
"command.message.next.description": "Go to the next user message",
"command.model.choose": "Choose model",
"command.model.choose.description": "Select a different model",
"command.mcp.toggle": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"command.mcp.toggle": "Manage MCP servers",
"command.mcp.toggle.description": "Enable or disable MCP servers",
"command.agent.cycle": "Cycle agent",
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
@@ -287,6 +287,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add files",
"prompt.menu.addImagesAndFiles": "Add files and more",
"prompt.menu.imagesAndFiles": "Images and files",
"prompt.menu.commands": "Commands",
"prompt.menu.context": "Context",
"prompt.menu.shellCommand": "Shell command",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
@@ -302,9 +307,9 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
"dialog.mcp.title": "MCPs",
"dialog.mcp.title": "MCP servers",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.mcp.empty": "No MCP servers configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
@@ -622,6 +627,7 @@ export const dict = {
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"session.tab.session": "Session",
"session.tab.review": "Review",
@@ -699,6 +705,9 @@ export const dict = {
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.reveal.finder": "Reveal in Finder",
"session.header.reveal.fileExplorer": "Reveal in File Explorer",
"session.header.reveal.containingFolder": "Open containing folder",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
+7
View File
@@ -286,6 +286,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
"prompt.context.removeFile": "Eliminar archivo del contexto",
"prompt.action.attachFile": "Adjuntar archivo",
"prompt.menu.addImagesAndFiles": "Añadir archivos y más",
"prompt.menu.imagesAndFiles": "Imágenes y archivos",
"prompt.menu.commands": "Comandos",
"prompt.menu.context": "Contexto",
"prompt.menu.shellCommand": "Comando de shell",
"prompt.attachment.remove": "Eliminar adjunto",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Detener",
@@ -604,6 +609,8 @@ export const dict = {
"home.sessions.group.today": "Hoy",
"home.sessions.group.yesterday": "Ayer",
"home.sessions.group.older": "Anteriores",
"home.providerTip":
"Conéctate a más de 75 proveedores para usar otros modelos, como Claude, GPT, Gemini y muchos más",
"session.tab.session": "Sesión",
"session.tab.review": "Revisión",
+7
View File
@@ -267,6 +267,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
"prompt.context.removeFile": "Retirer le fichier du contexte",
"prompt.action.attachFile": "Joindre un fichier",
"prompt.menu.addImagesAndFiles": "Ajouter des fichiers et plus encore",
"prompt.menu.imagesAndFiles": "Images et fichiers",
"prompt.menu.commands": "Commandes",
"prompt.menu.context": "Contexte",
"prompt.menu.shellCommand": "Commande shell",
"prompt.attachment.remove": "Supprimer la pièce jointe",
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
@@ -557,6 +562,8 @@ export const dict = {
"home.sessions.group.today": "Aujourd'hui",
"home.sessions.group.yesterday": "Hier",
"home.sessions.group.older": "Plus anciennes",
"home.providerTip":
"Connectez-vous à plus de 75 fournisseurs pour utiliser dautres modèles, notamment Claude, GPT, Gemini, etc.",
"session.tab.session": "Session",
"session.tab.review": "Revue",
"session.tab.context": "Contexte",
+6
View File
@@ -266,6 +266,11 @@ export const dict = {
"prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除",
"prompt.context.removeFile": "コンテキストからファイルを削除",
"prompt.action.attachFile": "ファイルを添付",
"prompt.menu.addImagesAndFiles": "ファイルなどを追加",
"prompt.menu.imagesAndFiles": "画像とファイル",
"prompt.menu.commands": "コマンド",
"prompt.menu.context": "コンテキスト",
"prompt.menu.shellCommand": "シェルコマンド",
"prompt.attachment.remove": "添付ファイルを削除",
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
@@ -550,6 +555,7 @@ export const dict = {
"home.sessions.group.today": "今日",
"home.sessions.group.yesterday": "昨日",
"home.sessions.group.older": "それ以前",
"home.providerTip": "75以上のプロバイダーに接続して、Claude、GPT、Geminiなどの他のモデルを利用できます",
"session.tab.session": "セッション",
"session.tab.review": "レビュー",
"session.tab.context": "コンテキスト",
+6
View File
@@ -254,6 +254,11 @@ export const dict = {
"prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거",
"prompt.context.removeFile": "컨텍스트에서 파일 제거",
"prompt.action.attachFile": "파일 첨부",
"prompt.menu.addImagesAndFiles": "파일 및 기타 항목 추가",
"prompt.menu.imagesAndFiles": "이미지 및 파일",
"prompt.menu.commands": "명령어",
"prompt.menu.context": "컨텍스트",
"prompt.menu.shellCommand": "셸 명령",
"prompt.attachment.remove": "첨부 파일 제거",
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
@@ -944,6 +949,7 @@ export const dict = {
"home.sessions.group.today": "오늘",
"home.sessions.group.yesterday": "어제",
"home.sessions.group.older": "이전",
"home.providerTip": "75개 이상의 제공업체에 연결하여 Claude, GPT, Gemini 등의 다른 모델을 사용하세요",
"session.tab.unknown": "알 수 없는 세션",
"session.error.notFound": "이 세션을 찾을 수 없습니다",
+7
View File
@@ -277,6 +277,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
"prompt.context.removeFile": "Fjern fil fra kontekst",
"prompt.action.attachFile": "Legg ved fil",
"prompt.menu.addImagesAndFiles": "Legg til filer og mer",
"prompt.menu.imagesAndFiles": "Bilder og filer",
"prompt.menu.commands": "Kommandoer",
"prompt.menu.context": "Kontekst",
"prompt.menu.shellCommand": "Shell-kommando",
"prompt.attachment.remove": "Fjern vedlegg",
"prompt.action.send": "Send",
"prompt.action.stop": "Stopp",
@@ -1037,6 +1042,8 @@ export const dict = {
"home.sessions.group.today": "I dag",
"home.sessions.group.yesterday": "I går",
"home.sessions.group.older": "Eldre",
"home.providerTip":
"Koble til over 75 leverandører for å bruke andre modeller, inkludert Claude, GPT, Gemini og flere",
"session.tab.unknown": "Ukjent sesjon",
"session.error.notFound": "Denne sesjonen finnes ikke",
+1 -1
View File
@@ -42,7 +42,7 @@ const domains = [
},
] as const
describe("i18n parity", () => {
describe.skipIf(!!process.env.CI)("i18n parity", () => {
test("non-English locales have every English key", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
+7
View File
@@ -268,6 +268,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu",
"prompt.context.removeFile": "Usuń plik z kontekstu",
"prompt.action.attachFile": "Załącz plik",
"prompt.menu.addImagesAndFiles": "Dodaj pliki i inne elementy",
"prompt.menu.imagesAndFiles": "Obrazy i pliki",
"prompt.menu.commands": "Polecenia",
"prompt.menu.context": "Kontekst",
"prompt.menu.shellCommand": "Polecenie powłoki",
"prompt.attachment.remove": "Usuń załącznik",
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
@@ -553,6 +558,8 @@ export const dict = {
"home.sessions.group.today": "Dzisiaj",
"home.sessions.group.yesterday": "Wczoraj",
"home.sessions.group.older": "Starsze",
"home.providerTip":
"Połącz się z ponad 75 dostawcami, aby korzystać z innych modeli, w tym Claude, GPT, Gemini i innych",
"session.tab.session": "Sesja",
"session.tab.review": "Przegląd",
"session.tab.context": "Kontekst",
+7
View File
@@ -286,6 +286,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Удалить активный файл из контекста",
"prompt.context.removeFile": "Удалить файл из контекста",
"prompt.action.attachFile": "Прикрепить файл",
"prompt.menu.addImagesAndFiles": "Добавить файлы и другое",
"prompt.menu.imagesAndFiles": "Изображения и файлы",
"prompt.menu.commands": "Команды",
"prompt.menu.context": "Контекст",
"prompt.menu.shellCommand": "Команда оболочки",
"prompt.attachment.remove": "Удалить вложение",
"prompt.action.send": "Отправить",
"prompt.action.stop": "Остановить",
@@ -604,6 +609,8 @@ export const dict = {
"home.sessions.group.today": "Сегодня",
"home.sessions.group.yesterday": "Вчера",
"home.sessions.group.older": "Ранее",
"home.providerTip":
"Подключитесь к более чем 75 провайдерам, чтобы использовать другие модели, включая Claude, GPT, Gemini и другие",
"session.tab.session": "Сессия",
"session.tab.review": "Обзор",
+6
View File
@@ -286,6 +286,11 @@ export const dict = {
"prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
"prompt.context.removeFile": "เอาไฟล์ออกจากบริบท",
"prompt.action.attachFile": "แนบไฟล์",
"prompt.menu.addImagesAndFiles": "เพิ่มไฟล์และอื่น ๆ",
"prompt.menu.imagesAndFiles": "รูปภาพและไฟล์",
"prompt.menu.commands": "คำสั่ง",
"prompt.menu.context": "บริบท",
"prompt.menu.shellCommand": "คำสั่งเชลล์",
"prompt.attachment.remove": "เอาไฟล์แนบออก",
"prompt.action.send": "ส่ง",
"prompt.action.stop": "หยุด",
@@ -599,6 +604,7 @@ export const dict = {
"home.sessions.group.today": "วันนี้",
"home.sessions.group.yesterday": "เมื่อวาน",
"home.sessions.group.older": "ก่อนหน้านี้",
"home.providerTip": "เชื่อมต่อกับผู้ให้บริการกว่า 75 รายเพื่อใช้โมเดลอื่นๆ รวมถึง Claude, GPT, Gemini และอีกมากมาย",
"session.tab.session": "เซสชัน",
"session.tab.review": "ตรวจสอบ",
+6
View File
@@ -291,6 +291,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Aktif dosyayı bağlamdan çıkar",
"prompt.context.removeFile": "Dosyayı bağlamdan çıkar",
"prompt.action.attachFile": "Dosya ekle",
"prompt.menu.addImagesAndFiles": "Dosya ve daha fazlasını ekle",
"prompt.menu.imagesAndFiles": "Görseller ve dosyalar",
"prompt.menu.commands": "Komutlar",
"prompt.menu.context": "Bağlam",
"prompt.menu.shellCommand": "Kabuk komutu",
"prompt.attachment.remove": "Eki kaldır",
"prompt.action.send": "Gönder",
"prompt.action.stop": "Durdur",
@@ -608,6 +613,7 @@ export const dict = {
"home.sessions.group.today": "Bugün",
"home.sessions.group.yesterday": "Dün",
"home.sessions.group.older": "Daha eski",
"home.providerTip": "Claude, GPT, Gemini ve diğer modelleri kullanmak için 75'ten fazla sağlayıcıya bağlanın",
"session.tab.session": "Oturum",
"session.tab.review": "İnceleme",
+7
View File
@@ -288,6 +288,11 @@ export const dict = {
"prompt.context.removeActiveFile": "Видалити активний файл з контексту",
"prompt.context.removeFile": "Видалити файл з контексту",
"prompt.action.attachFile": "Додати файли",
"prompt.menu.addImagesAndFiles": "Додати файли та інше",
"prompt.menu.imagesAndFiles": "Зображення та файли",
"prompt.menu.commands": "Команди",
"prompt.menu.context": "Контекст",
"prompt.menu.shellCommand": "Команда оболонки",
"prompt.attachment.remove": "Видалити вкладення",
"prompt.action.send": "Надіслати",
"prompt.action.stop": "Зупинити",
@@ -626,6 +631,8 @@ export const dict = {
"home.sessions.group.today": "Сьогодні",
"home.sessions.group.yesterday": "Учора",
"home.sessions.group.older": "Раніше",
"home.providerTip":
"Підключіться до понад 75 провайдерів, щоб використовувати інші моделі, зокрема Claude, GPT, Gemini та інші",
"session.tab.session": "Сесія",
"session.tab.review": "Огляд",
+6
View File
@@ -306,6 +306,11 @@ export const dict = {
"prompt.context.removeActiveFile": "从上下文移除活动文件",
"prompt.context.removeFile": "从上下文移除文件",
"prompt.action.attachFile": "附加文件",
"prompt.menu.addImagesAndFiles": "添加文件及更多内容",
"prompt.menu.imagesAndFiles": "图片和文件",
"prompt.menu.commands": "命令",
"prompt.menu.context": "上下文",
"prompt.menu.shellCommand": "shell 命令",
"prompt.attachment.remove": "移除附件",
"prompt.action.send": "发送",
"prompt.action.stop": "停止",
@@ -600,6 +605,7 @@ export const dict = {
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"home.providerTip": "连接 75 个以上的提供商,使用 Claude、GPT、Gemini 等其他模型",
"session.tab.session": "会话",
"session.tab.review": "审查",
+6
View File
@@ -286,6 +286,11 @@ export const dict = {
"prompt.context.removeActiveFile": "從上下文移除目前檔案",
"prompt.context.removeFile": "從上下文移除檔案",
"prompt.action.attachFile": "附加檔案",
"prompt.menu.addImagesAndFiles": "新增檔案及更多內容",
"prompt.menu.imagesAndFiles": "圖片和檔案",
"prompt.menu.commands": "命令",
"prompt.menu.context": "上下文",
"prompt.menu.shellCommand": "shell 命令",
"prompt.attachment.remove": "移除附件",
"prompt.action.send": "傳送",
"prompt.action.stop": "停止",
@@ -595,6 +600,7 @@ export const dict = {
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"home.providerTip": "連接 75 個以上的供應商,使用 Claude、GPT、Gemini 等其他模型",
"session.tab.session": "工作階段",
"session.tab.review": "審查",
+20
View File
@@ -68,6 +68,7 @@ import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
@@ -1030,8 +1031,24 @@ function HomeProjectRow(props: {
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const platform = usePlatform()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
const canRevealInFileManager = () =>
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
const fileManagerActionLabel = () =>
props.language.t(
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
)
const revealInFileManager = () => {
if (!platform.openPath) return
platform.openPath(props.project.worktree).catch((err: unknown) =>
showToast({
title: props.language.t("common.requestFailed"),
description: errorMessage(err, props.language.t("common.requestFailed")),
}),
)
}
return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
@@ -1073,6 +1090,9 @@ function HomeProjectRow(props: {
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={canRevealInFileManager()}>
<MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.clearNotifications(props.server, props.project)}
+2 -1
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
@@ -39,6 +39,7 @@ export default function NewLayout(props: ParentProps) {
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2 />
</div>
+2 -1
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2395,6 +2395,7 @@ export default function LegacyLayout(props: ParentProps) {
</div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} />
</div>
+101 -2
View File
@@ -1,8 +1,10 @@
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import { StatusPopoverV2 } from "@/components/status-popover"
@@ -24,8 +26,17 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
const providerTipExitDuration = 250
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -40,10 +51,15 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
useComposerCommands()
useComposerCommands({ model })
let inputRef: HTMLDivElement | undefined
@@ -51,6 +67,7 @@ export default function NewSessionPage() {
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
@@ -58,6 +75,16 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(),
})
command.register("new-session", () => [
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => inputRef?.focus(),
},
])
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
@@ -174,9 +201,81 @@ export default function NewSessionPage() {
</Show>
</div>
</NewSessionDesignView>
<ProviderTip
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
connected={() => providers.paid().length > 0}
openProviders={openProviderSettings}
/>
</div>
</div>
</div>
</div>
)
}
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
const language = useLanguage()
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
)
const visible = createMemo(
() =>
props.ready() &&
persistedReady() &&
!props.connected() &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
function dismiss() {
setPersistedState("dismissedAt", Date.now())
}
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => visible(),
element: () => ref() ?? null,
})
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
}}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={props.openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
>
<IconV2 name="xmark-small" />
</button>
</TooltipV2>
</div>
</div>
</Show>
)
}
+139 -39
View File
@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -11,6 +11,7 @@ import {
createMemo,
createEffect,
createComputed,
createSignal,
on,
onMount,
type ParentProps,
@@ -68,13 +69,19 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
SESSION_PANEL_WIDTH_MIN,
sessionPanelWidthMax,
} from "@/pages/session/session-panel-width"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
@@ -100,7 +107,6 @@ type VcsMode = "git" | "branch"
const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
})
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
@@ -347,6 +353,8 @@ export default function Page() {
const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
@@ -440,12 +448,42 @@ export default function Page() {
newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
)
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
let panelRow: HTMLDivElement | undefined
const [panelRowWidth, setPanelRowWidth] = createSignal<number>()
createResizeObserver(
() => panelRow,
({ width }) => setPanelRowWidth(width),
)
const splitReview = createMemo(
() => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split",
)
// The observer reports the content-box width, which already excludes the row
// padding; only the flex gap between the panels remains to subtract.
const sessionPanelAvailable = createMemo(() => {
const width = panelRowWidth()
if (width === undefined) return undefined
return width - (settings.general.newLayoutDesigns() ? 8 : 0)
})
const sessionPanelMax = createMemo(() => {
const available = sessionPanelAvailable()
if (available === undefined) return 1000
return sessionPanelWidthMax({ available, split: splitReview() })
})
// Clamp at render time so window or sidebar resizes squeeze the chat panel
// instead of the review pane, without overwriting the persisted width.
const sessionPanelResizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
available: sessionPanelAvailable(),
split: splitReview(),
}),
)
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
@@ -519,6 +557,17 @@ export default function Page() {
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect(
on(
() => ({ dir: sdk().directory, id: params.id }),
@@ -604,7 +653,8 @@ export default function Page() {
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
const mode = reviewMode()
if (mode === "git" || mode === "branch") return mode
})
const vcsKey = createMemo(
() =>
@@ -631,17 +681,63 @@ export default function Page() {
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
const selected = reviewFile()
if (selected && diffs.some((diff) => diff.file === selected)) return selected
return diffs[0]?.file
}
const reviewCount = () => reviewDiffs().length
const hasReview = () => reviewCount() > 0
const reviewReady = () => {
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
return true
}
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
const mode = vcsMode()
if (!mode) return
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
const directory = reviewDiffDirectory(root, file)
const source = reviewDiffs().find((diff) => diff.file === file)
const valid = (diff: VcsFileDiff | undefined) => {
if (!diff || !source) return
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
if (reviewDiffNeedsLoad(diff)) return
return diff
}
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
sdk()
.client.vcs.diff({ mode, directory: scope, context })
.then((result) => result.data ?? []),
})
.then((diffs) => diffs.find((diff) => diff.file === file))
if (directory !== root) {
try {
const scoped = valid(await request(directory))
if (scoped) return scoped
} catch (error) {
console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error })
}
}
try {
const bounded = valid(await request(root, 3))
if (bounded) return bounded
} catch (error) {
console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error })
}
}
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -924,12 +1020,15 @@ export default function Page() {
}
createEffect(() => {
if (!layout.ready()) return
if (sync().status !== "complete") return
if (!sync().project) return
const list = changesOptions()
if (list.includes(store.changes)) return
const mode = reviewMode()
if (list.includes(mode)) return
const next = list[0]
if (!next) return
setStore("changes", next)
view().review.setMode(next)
})
createEffect(
@@ -949,7 +1048,6 @@ export default function Page() {
const [tree, setTree] = createStore({
reviewScroll: undefined as HTMLDivElement | undefined,
pendingDiff: undefined as string | undefined,
activeDiff: undefined as string | undefined,
})
createEffect(
@@ -959,7 +1057,6 @@ export default function Page() {
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
},
{ defer: true },
@@ -982,6 +1079,7 @@ export default function Page() {
setActiveMessage,
focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
const openReviewFile = createOpenReviewFile({
@@ -1006,9 +1104,9 @@ export default function Page() {
return (
<Select
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
@@ -1025,11 +1123,11 @@ export default function Page() {
<SelectV2
appearance="inline"
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
/>
)
}
@@ -1057,18 +1155,18 @@ export default function Page() {
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges")
if (reviewMode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (reviewMode() === "git" || reviewMode() === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
if (reviewMode() === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
@@ -1081,10 +1179,10 @@ export default function Page() {
}
const reviewEmptyV2 = () => {
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (store.changes === "turn" && nogit()) {
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
@@ -1106,7 +1204,7 @@ export default function Page() {
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
focusedFile={activeReviewFile()}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
@@ -1137,8 +1235,12 @@ export default function Page() {
},
diffs: reviewDiffs,
diffsReady: reviewReady,
get diffVersion() {
return vcsQuery.dataUpdatedAt
},
loadDiff: loadReviewDiff,
get activeFile() {
return tree.activeDiff
return activeReviewFile()
},
onSelectFile: focusReviewDiff,
get diffStyle() {
@@ -1176,7 +1278,7 @@ export default function Page() {
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={reviewPanelV2Rendered()}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
@@ -1251,7 +1353,8 @@ export default function Page() {
const focusReviewDiff = (path: string) => {
openReviewPanel()
view().review.openPath(path)
setTree({ activeDiff: path, pendingDiff: path })
view().review.setFile(path)
setTree("pendingDiff", path)
}
createEffect(() => {
@@ -2048,6 +2151,7 @@ export default function Page() {
<SessionRouteFrame>
<SessionHeader />
<div
ref={panelRow}
class="flex-1 min-h-0 flex flex-col md:flex-row"
classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(),
@@ -2086,9 +2190,9 @@ export default function Page() {
"-right-1": settings.general.newLayoutDesigns(),
}}
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
size={sessionPanelResizedWidth()}
min={SESSION_PANEL_WIDTH_MIN}
max={sessionPanelMax()}
onResize={(width) => {
size.touch()
layout.session.resize(width)
@@ -2098,7 +2202,7 @@ export default function Page() {
</Show>
</div>
<Show when={!newSessionDesign()}>
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
@@ -2108,7 +2212,7 @@ export default function Page() {
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
@@ -2117,13 +2221,8 @@ export default function Page() {
<Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop()}>
<div
classList={{
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
}}
>
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<div class="min-h-0 flex-1">
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
@@ -2133,7 +2232,8 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
activeDiff={tree.activeDiff}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
@@ -0,0 +1,133 @@
import { batch, createMemo, startTransition } from "solid-js"
import { useModels } from "@/context/models"
import type { ModelKey, ModelSelection } from "@/context/local"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useProviders } from "@/hooks/use-providers"
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
const sdk = useSDK()
const sync = useSync()
const models = useModels()
const prompt = usePrompt()
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const configured = () => {
const value = sync().data.config.model
if (!value) return
const [providerID, modelID] = value.split("/")
const model = { providerID, modelID }
if (valid(model)) return model
}
const recent = () => models.recent.list().find(valid)
const fallback = () => {
const defaults = providers.default()
return providers.connected().flatMap((provider) => {
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
}
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
if (!key) return
return models.find(key)
}
const recentModels = createMemo(() =>
models.recent
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
current,
recent: recentModels,
list: models.list,
cycle(direction: 1 | -1) {
const items = recentModels()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
variant: {
configured() {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
return prompt.model.current()?.variant
},
current() {
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
} satisfies ModelSelection
return selection
}
@@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
@@ -22,6 +22,7 @@ export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
const local = useLocal()
@@ -44,7 +45,7 @@ export function createPromptInputController(input: {
select: local.agent.set,
},
model: {
selection: local.model,
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
@@ -0,0 +1,118 @@
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SettingsProvider, useSettings } from "@/context/settings"
export default {
title: "Composer/Revert Dock",
id: "composer-revert-dock",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
### Source path
- \`packages/app/src/pages/session/composer/session-revert-dock.tsx\`
### Why the mock composer
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
### Layout split
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
### Notes
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
},
},
},
}
const messages = [
"update current branch with latest changes from dev and fix conflicts if any",
"investigate why the chat input loses focus after sending a message",
"Debug why streaming responses sometimes duplicate the last token",
"suggest a better title for this PR based on the diff",
"add a storybook story for the revert dock",
"reduce re-renders in the timeline component",
]
const btn = (accent?: boolean) =>
({
padding: "6px 12px",
"border-radius": "6px",
border: "1px solid var(--v2-border-border-base, #0000001a)",
background: accent ? "var(--v2-background-bg-contrast, #242424)" : "var(--v2-background-bg-base, #fff)",
color: accent ? "var(--v2-text-text-contrast, #fafafa)" : "var(--v2-text-text-base, #161616)",
cursor: "pointer",
"font-size": "13px",
}) as const
function Stage(props: { count: number }) {
const settings = useSettings()
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
const [store, setStore] = createStore({ items: seed() })
const v2 = () => settings.general.newLayoutDesigns()
const reset = () => setStore("items", seed())
const restore = (id: string) =>
setStore(
"items",
store.items.filter((item) => item.id !== id),
)
return (
<div style={{ display: "grid", gap: "16px", "max-width": "720px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<button style={btn()} onClick={reset}>
Reset ({props.count})
</button>
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
Layout: {v2() ? "v2" : "v1"}
</button>
</div>
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
<div style={{ display: "flex", "flex-direction": "column" }}>
<SessionRevertDock items={store.items} onRestore={restore} />
<div
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
class="min-h-24 w-full rounded-[12px] border border-v2-border-border-base bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
>
Ask anything...
</div>
</div>
<div class="text-[12px] text-v2-text-text-faint">
Restored so far:{" "}
<For each={seed()}>
{(item) => <span>{store.items.some((current) => current.id === item.id) ? "" : `${item.text}`}</span>}
</For>
</div>
</div>
)
}
const story = (count: number) => () => (
<SettingsProvider>
<Stage count={count} />
</SettingsProvider>
)
export const OneMessage = {
name: "1 rolled back",
render: story(1),
}
export const ThreeMessages = {
name: "3 rolled back",
render: story(3),
}
export const ManyMessages = {
name: "6 rolled back (scrolls)",
render: story(6),
}
@@ -3,7 +3,11 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
export function SessionRevertDock(props: {
items: { id: string; text: string }[]
@@ -12,6 +16,7 @@ export function SessionRevertDock(props: {
onRestore: (id: string) => void
}) {
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
collapsed: true,
})
@@ -31,69 +36,153 @@ export function SessionRevertDock(props: {
)
const preview = createMemo(() => props.items[0]?.text ?? "")
const onHeaderKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}
return (
<DockTray data-component="session-revert-dock">
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<DockTray data-component="session-revert-dock">
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
data-component="session-revert-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
<div
class="flex h-[42px] items-center gap-2 pl-4 pr-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<IconV2 name="outline-reset" size="normal" class="text-v2-icon-icon-muted" />
<span
classList={{
"font-[440] shrink-0 cursor-default text-[13px] leading-5 tracking-[-0.04px]": true,
"text-v2-text-text-base": !store.collapsed,
"text-v2-text-text-muted": store.collapsed,
}}
>
{label()}
</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
{preview()}
</span>
</Show>
<div class="ml-auto shrink-0">
<IconButtonV2
icon={<IconV2 name="outline-chevron-down" size="small" />}
size="large"
variant="ghost-muted"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
{/* Sacrificial space the composer overlaps via its negative lift (18px), so the header stays fully visible */}
<Show when={store.collapsed}>
<div class="h-[18px]" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
{/* Scroll viewport ends above the composer; the 18px sacrificial below is what the composer overlaps */}
<div class="flex max-h-42 flex-col gap-2 overflow-y-auto px-4 pt-px pb-3 no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex h-6 min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate text-[13px] font-[400] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{item.text}
</span>
<ButtonV2
size="small"
variant="neutral"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</ButtonV2>
</div>
)}
</For>
</div>
<div class="h-[18px]" aria-hidden="true" />
</Show>
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
</Show>
)
}
+13 -3
View File
@@ -172,6 +172,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
}
export function FileTabContent(props: { tab: string }) {
return (
<Tabs.Content value={props.tab}>
<SessionFileView tab={props.tab} />
</Tabs.Content>
)
}
export function SessionFileView(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@@ -439,8 +447,8 @@ export function FileTabContent(props: { tab: string }) {
</div>
)
return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
@@ -450,6 +458,8 @@ export function FileTabContent(props: { tab: string }) {
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</Tabs.Content>
</div>
)
return content()
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
createSessionTabs,
@@ -165,4 +166,49 @@ describe("createSessionTabs", () => {
dispose()
})
})
test("exposes the Open File tab without treating it as a file tab", () => {
createRoot((dispose) => {
const [state] = createStore({
active: SESSION_OPEN_FILE_TAB as string | undefined,
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
normalizeTab: (tab) => tab,
fileBrowser: () => true,
})
expect(result.openFileOpen()).toBe(true)
expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB])
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB)
dispose()
})
})
test("hides the Open File placeholder when the file browser is unavailable", () => {
createRoot((dispose) => {
const [state] = createStore({
active: SESSION_OPEN_FILE_TAB as string | undefined,
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
normalizeTab: (tab) => tab,
fileBrowser: () => false,
})
expect(result.openFileOpen()).toBe(false)
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe("file://src/a.ts")
dispose()
})
})
})
+19 -1
View File
@@ -2,6 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same"
import { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
const emptyTabs: string[] = []
@@ -16,6 +19,7 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@@ -27,8 +31,14 @@ export function shouldShowFileTree(input: { visible: boolean; opened: boolean })
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
const openFileOpen = createMemo(
() =>
fileBrowser() &&
(input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)),
)
const panelTabs = createMemo(
() => {
const seen = new Set<string>()
return input
@@ -36,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
@@ -45,9 +56,13 @@ export const createSessionTabs = (input: TabsInput) => {
emptyTabs,
{ equals: same },
)
const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
equals: same,
})
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
@@ -65,12 +80,15 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (!openedTabs().includes(active)) return
return active
})
return {
contextOpen,
openFileOpen,
panelTabs,
openedTabs,
activeTab,
activeFileTab,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({
@@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
expect(calls).toEqual(["reset"])
})
})
describe("syncPromptModel", () => {
test("stores the effective session model in prompt state", () => {
const calls: unknown[] = []
syncPromptModel(
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
},
},
{
model: {
current: () => undefined,
set: (model) => calls.push(model),
},
},
)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
})
test("does not rewrite an unchanged prompt model", () => {
const calls: unknown[] = []
const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
syncPromptModel(
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
},
},
{
model: {
current: () => model,
set: (value) => calls.push(value),
},
},
)
expect(calls).toEqual([])
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
})
})
@@ -7,6 +7,24 @@ type Local = {
}
}
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
type PromptState = {
model: {
current(): { providerID: string; modelID: string; variant?: string | null } | undefined
set(model: { providerID: string; modelID: string; variant?: string | null }): void
}
}
export const resetSessionModel = (local: Local) => {
local.session.reset()
}
@@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg)
}
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = local.model.current()
if (!model) return
const next = {
providerID: model.provider.id,
modelID: model.id,
variant: local.model.variant.current(),
}
const current = prompt.model.current()
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import {
clampSessionPanelWidth,
REVIEW_PANE_WIDTH_MIN,
REVIEW_PANE_WIDTH_MIN_SPLIT,
SESSION_PANEL_WIDTH_MIN,
sessionPanelWidthMax,
} from "./session-panel-width"
describe("sessionPanelWidthMax", () => {
test("reserves the unified review pane minimum", () => {
expect(sessionPanelWidthMax({ available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN)
})
test("reserves a larger minimum for split diffs", () => {
expect(sessionPanelWidthMax({ available: 1700, split: true })).toBe(1700 - REVIEW_PANE_WIDTH_MIN_SPLIT)
expect(REVIEW_PANE_WIDTH_MIN_SPLIT).toBeGreaterThan(REVIEW_PANE_WIDTH_MIN)
})
test("lets the chat panel take everything beyond the review pane minimum", () => {
// Regression: the old cap was 45% of the window, forcing the review pane
// to at least 55% of the window regardless of content.
const available = 3440
expect(sessionPanelWidthMax({ available, split: false })).toBeGreaterThan(available * 0.45)
})
test("never drops below the chat panel minimum on small windows", () => {
expect(sessionPanelWidthMax({ available: 600, split: true })).toBe(SESSION_PANEL_WIDTH_MIN)
expect(sessionPanelWidthMax({ available: 0, split: false })).toBe(SESSION_PANEL_WIDTH_MIN)
})
})
describe("clampSessionPanelWidth", () => {
test("keeps widths already within the limit", () => {
expect(clampSessionPanelWidth({ width: 800, available: 1700, split: false })).toBe(800)
})
test("forces the width down when the window shrinks", () => {
expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN)
expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: true })).toBe(
1700 - REVIEW_PANE_WIDTH_MIN_SPLIT,
)
})
test("holds the chat panel minimum when there is no room for both", () => {
expect(clampSessionPanelWidth({ width: 1600, available: 700, split: true })).toBe(SESSION_PANEL_WIDTH_MIN)
})
test("skips clamping before the layout is measured", () => {
expect(clampSessionPanelWidth({ width: 1600, available: undefined, split: false })).toBe(1600)
})
})
@@ -0,0 +1,19 @@
// The review pane has no width of its own: it takes whatever the chat panel
// leaves behind. Instead of capping the chat panel at a fraction of the window
// (which forces the review pane to grow with the monitor), reserve a fixed
// minimum for the review pane and let the chat panel take everything else.
export const SESSION_PANEL_WIDTH_MIN = 450
export const REVIEW_PANE_WIDTH_MIN = 480
export const REVIEW_PANE_WIDTH_MIN_SPLIT = 800
export function sessionPanelWidthMax(input: { available: number; split: boolean }) {
const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN
return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - pane)
}
// `available` is undefined until the layout row is first measured; render the
// stored width untouched until then to avoid a first-frame snap.
export function clampSessionPanelWidth(input: { width: number; available: number | undefined; split: boolean }) {
if (input.available === undefined) return input.width
return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split }))
}
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
@@ -26,6 +27,7 @@ import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
@@ -34,6 +36,7 @@ import {
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
export function SessionSidePanel(props: {
canReview: () => boolean
@@ -44,6 +47,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
@@ -71,7 +75,6 @@ export function SessionSidePanel(props: {
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const rendered = createMemo<boolean>((previous) => previous || open(), false)
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
@@ -147,15 +150,13 @@ export function SessionSidePanel(props: {
normalizeTab,
review: reviewTab,
hasReview: props.canReview,
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const reviewContentRendered = createMemo<boolean>(
(previous) => previous || (reviewOpen() && activeTab() === "review"),
false,
)
const fileTreeTab = () => layout.fileTree.tab()
@@ -172,6 +173,33 @@ export function SessionSidePanel(props: {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
return activeFileTab()
})
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -221,8 +249,10 @@ export function SessionSidePanel(props: {
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-background-base"
class="relative min-w-0 flex overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
@@ -233,173 +263,238 @@ export function SessionSidePanel(props: {
}}
style={{ width: panelWidth() }}
>
<Show when={rendered()}>
<Show when={open()}>
<div
class="size-full flex"
classList={{
"border-l border-border-weaker-base": !settings.general.newLayoutDesigns(),
}}
>
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
classList={{
"pointer-events-none": !reviewOpen(),
}}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
<Show when={reviewOpen()}>
<div
class="relative min-w-0 h-full flex-1 overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div
class="size-full min-w-0 h-full"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
aria-hidden={activeTab() !== "review"}
inert={activeTab() !== "review"}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
classList={{ hidden: activeTab() !== "review" }}
>
{props.reviewPanel()}
</div>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</div>
</Show>
<Show when={shown()}>
<Show when={fileOpen()}>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
@@ -427,45 +522,49 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
</Show>
<Show when={fileTreeTab() === "all"}>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Match>
</Switch>
</Tabs.Content>
</Show>
</Tabs>
</div>
<Show when={fileOpen()}>
@@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative shrink-0 overflow-hidden bg-background-stronger"
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
@@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -1243,12 +1243,13 @@ export function MessageTimeline(props: {
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const asyncFile = () => {
const tool = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool)
if (part?.type === "tool") return part
}
const asyncFile = () => ["edit", "write", "patch", "apply_patch"].includes(tool()?.tool ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1278,6 +1279,8 @@ export function MessageTimeline(props: {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
// Rounded virtual measurements can otherwise clip a framed row's outer paint.
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
@@ -1383,7 +1386,7 @@ export function MessageTimeline(props: {
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2": settings.general.newLayoutDesigns(),
"pl-2.5": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
@@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
@@ -14,7 +14,7 @@ const withCategory = (category: string) => {
})
}
export const useComposerCommands = () => {
export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
@@ -22,6 +22,7 @@ export const useComposerCommands = () => {
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
@@ -43,7 +44,7 @@ export const useComposerCommands = () => {
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
})
}
@@ -61,7 +62,7 @@ export const useComposerCommands = () => {
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
onSelect: () => model.variant.cycle(),
}),
agentCommand({
id: "agent.cycle",
@@ -25,6 +25,7 @@ export type SessionCommandContext = {
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -83,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
import { filterReviewFiles, reviewDiffDirectory, reviewDiffKinds, reviewDiffNeedsLoad } from "./review-diff-kinds"
describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => {
@@ -28,3 +28,43 @@ describe("filterReviewFiles", () => {
expect(filterReviewFiles(files, "")).toEqual(files)
})
})
describe("reviewDiffNeedsLoad", () => {
test("loads changed files whose aggregate patch has no hunks", () => {
expect(
reviewDiffNeedsLoad({
file: "src/a.ts",
additions: 1,
deletions: 0,
patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts",
}),
).toBe(true)
})
test("keeps complete patches and empty changes", () => {
expect(
reviewDiffNeedsLoad({
file: "src/a.ts",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+value",
}),
).toBe(false)
expect(reviewDiffNeedsLoad({ file: "empty.txt", additions: 0, deletions: 0 })).toBe(false)
})
})
describe("reviewDiffDirectory", () => {
test("scopes nested files to their parent directory", () => {
expect(reviewDiffDirectory("/repo", "src/lib/a.ts")).toBe("/repo/src/lib")
expect(reviewDiffDirectory("C:\\repo", "src/lib/a.ts")).toBe("C:\\repo\\src\\lib")
})
test("does not rescope root files", () => {
expect(reviewDiffDirectory("/repo/", "README.md")).toBe("/repo")
expect(reviewDiffDirectory("/", "README.md")).toBe("/")
expect(reviewDiffDirectory("C:\\", "README.md")).toBe("C:\\")
expect(reviewDiffDirectory("/", "src/a.ts")).toBe("/src")
expect(reviewDiffDirectory("C:\\", "src/a.ts")).toBe("C:\\src")
})
})
@@ -8,6 +8,24 @@ export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function reviewDiffNeedsLoad(diff: RenderDiff) {
if (diff.additions === 0 && diff.deletions === 0) return false
return !diff.patch || !/^@@ /m.test(diff.patch)
}
export function reviewRootDirectory(root: string) {
return root === "/" || /^[A-Za-z]:[/\\]?$/.test(root) ? root : root.replace(/[/\\]+$/, "")
}
export function reviewDiffDirectory(root: string, file: string) {
const path = normalizePath(file)
const index = path.lastIndexOf("/")
const separator = root.includes("\\") ? "\\" : "/"
const base = reviewRootDirectory(root)
if (index < 0) return base
return `${base.endsWith(separator) ? base : base + separator}${path.slice(0, index).replaceAll("/", separator)}`
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
@@ -1,4 +1,4 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
@@ -21,7 +21,12 @@ import type {
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
import {
filterReviewFiles,
reviewDiffKinds,
reviewDiffNeedsLoad,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
@@ -32,6 +37,8 @@ export type ReviewPanelV2Props = {
empty?: JSX.Element
diffs: () => ReviewDiff[]
diffsReady: () => boolean
diffVersion?: number
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
activeFile?: string
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
@@ -69,7 +76,26 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
if (active && files.includes(active)) return active
return files[0]
})
const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const sourceActiveItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const detailSource = createMemo(() => {
const diff = sourceActiveItem()
const load = props.loadDiff
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
return { diff, load, version: props.diffVersion }
})
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
const value = await load(diff.file, version)
if (value?.file !== diff.file) return
return { source: diff, version, value }
})
const activeItem = createMemo(() => {
const source = sourceActiveItem()
if (loadedDiff.state !== "ready") return source
const loaded = loadedDiff()
if (loaded && loaded.source === source && loaded.version === props.diffVersion) return loaded.value
return source
})
const readFile = async (path: string) =>
sdk()
@@ -0,0 +1,194 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import {
SessionFilePanelV2,
SessionFilePanelV2Empty,
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { displayName } from "@/pages/layout/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { pathKey } from "@/utils/path-key"
const emptyFiles: string[] = []
export type SessionFileBrowserState = {
sidebarOpened: () => boolean
sidebarWidth: () => number
resizeSidebar: (width: number) => void
toggleSidebar: () => void
}
export function SessionFileBrowserTab(props: {
tab: string
placeholder: boolean
active?: string
kinds: ReadonlyMap<string, Kind>
state: SessionFileBrowserState
onSelect: (path: string) => void
onSelectPermanent: (path: string) => void
filterRef?: (element: HTMLInputElement) => void
}) {
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const sdk = useSDK()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
const value = query()
return {
queryKey: ["session-open-file", workspaceKey(), value] as const,
enabled: value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
}
})
const files = createMemo(() => {
if (!query() || search.isPending) return emptyFiles
return [...new Set(search.data ?? emptyFiles)]
})
const highlighted = createMemo(() => {
const values = files()
if (values.length === 0) return undefined
const explicit = explicitHighlight()
if (explicit && values.includes(explicit)) return explicit
return values[0]
})
const loading = createMemo(() => query().length > 0 && search.isPending)
const project = createMemo(() => {
const directory = pathKey(sdk().directory)
return layout.projects
.list()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
})
const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (event.key === "Escape" && query()) {
event.preventDefault()
setFilter("")
return
}
if (!query()) return
applyFileListKeyDown(event, files(), highlighted(), {
onHighlight: setExplicitHighlight,
onSelect: props.onSelectPermanent,
})
}
return (
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={query()}
fallback={
<FileTree
path=""
class="pt-1"
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
>
<Show
when={!loading()}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
</Show>
</Show>
</SessionReviewV2Sidebar>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
</Tabs.Content>
)
}
@@ -43,7 +43,11 @@ export function SessionFileListV2(props: {
active?: string
highlighted?: string
kinds?: ReadonlyMap<string, Kind>
id?: string
role?: "listbox"
optionID?: (path: string) => string
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
@@ -88,6 +92,8 @@ export function SessionFileListV2(props: {
return (
<div
ref={setRoot}
id={props.id}
role={props.role}
data-component="file-tree-v2"
data-total-rows={props.files.length}
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
@@ -116,6 +122,9 @@ export function SessionFileListV2(props: {
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
@@ -124,6 +133,7 @@ export function SessionFileListV2(props: {
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />

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