Compare commits

...
Author SHA1 Message Date
Aiden Cline 9b1dcbb45c test: update server and sdk fixtures to context options bag 2026-09-04 01:10:34 -05:00
Aiden Cline 542e79efd5 fix(core): keep title requests out of session context hooks
Titles will get a dedicated hook. The context event's kind excludes
"title" so plugins cannot branch on a value they never receive.
2026-09-04 01:06:36 -05:00
Aiden Cline 7e228666e9 Revert "refactor(core): detect warm requests by kind instead of prompt text"
This reverts commit a52c7ab089.
2026-09-04 01:02:26 -05:00
Aiden Cline a52c7ab089 refactor(core): detect warm requests by kind instead of prompt text 2026-09-04 00:57:20 -05:00
Aiden Cline 986ee8c060 feat(plugin): run session context hooks for every request kind
The context hook now fires for titles too and carries `kind`, so one rule
covers every outbound request: plugins that only want the agent loop
filter on `kind` instead of relying on the hook not firing.

`generation` and `providerOptions` collapse into one `options` bag. The
typed keys are the protocol-neutral generation settings; any other key is
passed to the protocol as a provider option. Core partitions them when it
builds the request.

Compaction no longer borrows the hidden `compaction` agent as its hook
identity. Every hook sees the session's agent and `kind: "compaction"`,
which removes the `contextAgentID` workaround. Retry hooks gain `kind`
so compaction retries stay distinguishable.
2026-09-04 00:50:22 -05:00
Aiden Cline 89478b36f1 refactor(plugin): rename session request kind "session" to "primary" (#47221) 2026-09-04 00:12:18 -05:00
Aiden Cline 46458f0753 feat(core): tag session http hooks with request kind (#47214) 2026-09-03 23:51:26 -05:00
opencode-agent[bot]andrekram1-node c907d2ba27 fix(tui): preserve model release ordering in search (#47183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 23:18:09 -05:00
Aiden Cline ffac1c5b11 fix(core): classify GitHub Copilot requests on every route (#47160) 2026-09-03 22:13:04 -05:00
opencode-agent[bot]andrekram1-node c5dca2df37 feat(core): suggest moving sessions into task worktrees (#47202)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 22:06:15 -05:00
Aiden Cline 8889447f5a fix(core): scope compaction Completed section to finished work (#47203) 2026-09-03 22:05:25 -05:00
opencode-agent[bot]andHona a1cb005799 fix(app): remove timeline settings advanced divider (#47196)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 12:25:33 +10:00
Kit Langton 19833ad1fd feat(simulation): expose and record real mouse input (#47194) 2026-09-04 02:17:33 +00:00
Kit Langton cb852434b1 fix(tui): preserve selection under a stationary pointer (#47138) 2026-09-04 02:04:29 +00:00
opencode-agent[bot]andiamdavidhill b3733e9517 fix(app): add bottom padding to settings pages (#47191)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 01:58:55 +00:00
opencode-agent[bot]andHona 331f4ecd2f fix(app): contain preferences scroll overflow (#47190)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 01:54:52 +00:00
Luke Parker ecaa914b79 fix(app): align Windows app icon in title bar (#47189) 2026-09-04 01:54:22 +00:00
Aiden Cline 4f8dea674a refactor(ai): drop converse tool deltas without an open block (#47182) 2026-09-03 20:50:35 -05:00
Aiden Cline 4bf5269c4c fix(core): retry transient compaction failures (#47159) 2026-09-03 20:45:47 -05:00
Kit Langton c9d240704d fix(core): settle abandoned compactions before resuming sessions (#47178) 2026-09-04 01:02:29 +00:00
Kit Langton c9df4ba80d fix(util): skip unused audits during package installs (#47176) 2026-09-04 00:58:29 +00:00
David HillandLukeParkerDev ac71a55294 fix(app): keep right panel controls aligned (#46996)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 00:54:39 +00:00
Aiden Cline f84d927e07 refactor(ai): drop reasoning items when they finish (#47100) 2026-09-03 19:53:22 -05:00
d431fedce5 test(core): disable npm audits in the test preload (#47170)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:52:18 -05:00
Kit Langton e536b9627e feat(tui): inspect live shell output (#47134) 2026-09-04 00:47:58 +00:00
David HillandLukeParkerDev 6af46cc8a9 feat(app): add settings to vertical tabs (#47119)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:45:09 +10:00
David HillandLukeParkerDev c76e602ba4 fix(app): keep session tab labels stable during creation (#47099)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:33:51 +10:00
opencode-agent[bot]andrekram1-node 726107729e test(core): align Code Mode catalog scope assertions (#47169)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:30:25 -05:00
David Hill 0808ebc3c5 fix(app): refine build indicators in tab navigation (#47112) 2026-09-04 09:57:32 +10:00
David HillandLukeParkerDev 17e5c5fbf1 feat(app): show vertical sidebar shortcut hints (#47122)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:42:29 +10:00
David Hill b0a13b810e fix(app): allow new session shortcut from settings (#47123) 2026-09-04 09:42:14 +10:00
Luke Parker 6575215ddf fix(desktop): keep command palette responsive and scoped (#47164) 2026-09-04 09:41:12 +10:00
David HillandLukeParkerDev 09157609b7 feat(app): add session project menu and polish header (#46977)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:25:31 +10:00
Luke Parker 7ba5f3e5b2 fix(core): restore Ctrl+C in Windows terminals (#47163) 2026-09-04 09:16:14 +10:00
David HillandLukeParkerDev 0ec1587948 feat(app): add about settings page (#45782)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-03 23:07:41 +00:00
David Hill 2fda2c8141 feat(app): give experimental settings a dedicated page (#46973) 2026-09-04 08:33:29 +10:00
Luke Parker f06bf47e72 fix(app): preserve slash commands when creating worktrees (#47155) 2026-09-03 22:25:42 +00:00
opencode-agent[bot]andHona fa6e7e179d fix(desktop): render before telemetry (#47102)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 08:21:16 +10:00
Dax Raad 4272bb83e0 feat(cli): support command aliases with update alias for upgrade 2026-09-03 18:10:01 -04:00
usrnk1andiamdavidhill 1ae2cf3567 feat(desktop): polish menus, file tabs, and empty states (#47095)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 07:55:27 +10:00
Dax Raad 2084c52952 fix(core): clarify code mode tool scope 2026-09-03 17:21:06 -04:00
Aiden Cline 5a9931280e feat(core): add Modal live model backfill plugin (#47136) 2026-09-03 16:03:21 -05:00
Aiden Cline b2fb2c5e36 chore(core): bump GitHub Copilot API version to 2026-08-01 (#47108) 2026-09-03 15:38:32 -05:00
James Long 43bd2a516b refactor(cli): move update checks to TUI clients (#46963) 2026-09-03 14:55:26 -04:00
Kit Langton dad7688739 refactor(core): reuse job scope as generation identity (#47121) 2026-09-03 14:47:44 -04:00
Kit Langton 961b8ccb86 refactor(core): remove unobservable websocket states (#47117) 2026-09-03 14:24:45 -04:00
Kit Langton 97303c39dd fix(core): expand portable shell scanner compatibility (#47110) 2026-09-03 14:11:58 -04:00
Kit Langton 282c84d79f refactor(core): remove redundant step closeout flags (#46937) 2026-09-03 13:35:42 -04:00
Kit Langton 2dcfc89fab feat(tui): announce server plugin failures (#47103)
Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
2026-09-03 13:17:10 -04:00
Kit Langton a222401f19 fix(core): normalize RPC handler failures (#46946) 2026-09-03 13:15:24 -04:00
Kit Langton 36da0d5c77 fix(core): retry failed location initialization (#46957) 2026-09-03 13:10:59 -04:00
Shoubhit Dash 7819e7f503 fix(core): run command subagents in the background (#47081) 2026-09-03 22:37:58 +05:30
Aiden Cline 6e63b970f3 feat(core): persist compaction model and provider state (#46962) 2026-09-03 12:06:50 -05:00
Kit Langton 610d7e952a refactor(client): remove redundant location spreads (#47086) 2026-09-03 12:51:12 -04:00
Kit Langton ac874a6e90 fix(core): recover idle moves through the selected instance (#46955) 2026-09-03 16:45:16 +00:00
Kit Langton f40ecefdef feat(core): disable plugins after transform failures (#47083)
Disable failed registration groups, rebuild healthy state, and report plugin failures with safe diagnostic references. Keep cleanup outside activation locks and preserve disabled revisions across unrelated reloads. Cover deferred hook and RPC cleanup with real-service regression tests.
2026-09-03 16:38:11 +00:00
Kit Langton c370a1bdd0 refactor(app): remove redundant project setter wrappers (#47085) 2026-09-03 16:33:41 +00:00
Kit Langton 309f4534fa test(app): align timeline assertions with activity controls (#47089) 2026-09-03 12:20:08 -04:00
Kit Langton 0ae3bf743f fix(tui): show execution failures in the viewed session (#46968) 2026-09-03 12:00:48 -04:00
Kit Langton f94eefaa50 fix(tui): preserve markdown blocks on plugin toggles (#47084) 2026-09-03 11:37:41 -04:00
Kit Langton a04d72bb39 fix(sdk): bind embedded transport at request time (#46971) 2026-09-03 11:16:22 -04:00
Aiden Cline 206f51547c fix(core): reject GitHub Copilot login without chat entitlement (#46959) 2026-09-03 09:56:36 -05:00
Aiden Cline 5716f8ba60 feat(ai): add UnsupportedOperation error for route capability mismatches (#46960) 2026-09-03 09:44:36 -05:00
Aiden Cline f98a6286da refactor(ai): drop responses replay tombstones (#46965) 2026-09-03 09:39:47 -05:00
usrnk1 de365ecbaa feat(desktop): reflect saved project colors (#46787) 2026-09-03 16:37:57 +02:00
usrnk1 b3b08c9a04 feat(desktop): update blue accent styling (#47009) 2026-09-03 16:31:15 +02:00
usrnk1 4fcb59e4c7 feat(desktop): polish session activity controls (#47033) 2026-09-03 16:23:54 +02:00
OpeOginni c7263309d4 fix(desktop): wait for session export (#46435) 2026-09-03 14:04:23 +00:00
opencode-agent[bot]andthdxr 5d8a01dedc feat(tui): add copy session ID command (#47064)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-03 13:50:21 +00:00
OpeOginniandBrendan Allan 0f6393dab1 feat(app): add desktop session import (#46416)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 13:14:40 +00:00
Shoubhit Dash 59b29de409 fix(core): detect new ecosystem config roots (#47026) 2026-09-03 16:09:58 +05:30
opencode-agent[bot]andHona 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
opencode-agent[bot]andvimtor d2bec894bf fix(app): identify desktop in Console device auth (#47001)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-03 11:50:45 +02:00
opencode-agent[bot] d7009156a6 chore: update nix node_modules hashes 2026-09-03 08:56:58 +00:00
4cfca7ab68 fix(app): restore terminal application mouse handling (#45140)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 16:39:27 +08:00
Luke Parker ae7d9f029d fix(session-ui): preserve configured groups on tool failure (#46998) 2026-09-03 07:30:50 +00:00
Luke Parker 9acdb0be18 fix(session-ui): use standard Used labels for thoughts (#46995) 2026-09-03 07:10:56 +00:00
b42555cf01 fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 16:45:17 +10:00
Luke Parker cfe9f13963 fix(ui): capitalize notices in used group (#46992) 2026-09-03 06:43:01 +00:00
David Hill 48f246695e fix(app): align new session icon (#46983) 2026-09-03 16:02:01 +10:00
Brendan Allan 4f6060ad94 feat(app): route settings and refine shell styling (#46984) 2026-09-03 13:51:07 +08:00
opencode-agent[bot]andHona bf6ec61a74 fix(app): remove background running indicator (#46972)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 15:40:55 +10:00
Dax Raad cf298f3409 fix(cli): use artifact as client identity 2026-09-03 00:11:34 -04:00
Dax Raad 0089ac9b02 fix(cli): align artifact user agent format 2026-09-03 00:09:48 -04:00
Kit Langton 4680a4aa6f refactor(core): reconcile current watcher policy (#46949) 2026-09-02 23:15:25 -04:00
Luke Parker 88e4ab5735 feat(app): add timeline detail presets and placement controls (#46717) 2026-09-03 13:10:57 +10:00
Kit Langton efefd90443 feat(plugin): add reference editor lookup 2026-09-03 01:59:36 +00:00
Dax Raad 2b87169cc1 feat(tui): polish plugin dialog sizing, actions, and local footer 2026-09-02 21:55:48 -04:00
Kit Langton 962c26bdf2 feat(plugin): add skill editor lookup 2026-09-02 21:55:00 -04:00
David Hill 22de01e84f fix(app): animate subagent card chevron (#46893) 2026-09-03 09:47:53 +08:00
Aiden Cline 8565cb52a1 chore(ai): clean up responses item id comments (#46951) 2026-09-02 20:44:30 -05:00
Kit Langton 050398f51f fix(core): preserve provider identity in catalog updates 2026-09-03 01:44:12 +00:00
Kit Langton 5f1d74fd3f fix(plugin): export Promise ToolEditor 2026-09-03 01:40:03 +00:00
Aiden Cline d9c85d8d95 refactor(ai): resolve responses item ids once at the stream boundary (#46885) 2026-09-02 20:31:00 -05:00
Kit Langton 1c77b1c920 refactor(core): remove unused repository cache success timestamp (#46942) 2026-09-02 21:17:59 -04:00
Kit Langton 27f838f249 fix(client): refresh references for the updated location (#46935) 2026-09-02 21:12:52 -04:00
Kit Langton c992716523 fix(core): activate the initial plugin generation without the reload debounce (#46922) 2026-09-03 01:02:38 +00:00
Kit Langton e402600d92 test(core): keep supervisor reload tests offline (#46939) 2026-09-02 21:02:16 -04:00
opencode-agent[bot]andHona 1e4e9c5d85 fix(cli): disable bytecode until Bun 1.4.1 (#46933)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 10:43:09 +10:00
Kit Langton f34b74b1dc test(core): refuse network in the test harness (#46908) 2026-09-02 20:41:29 -04:00
Kit Langton b257d476c9 refactor(core): preserve normalized tool results (#46927) 2026-09-02 20:39:55 -04:00
Kit Langton 5e38101fba refactor(client): derive session inputs from pending items (#46926) 2026-09-02 20:24:35 -04:00
mayamikaandClaude Opus 5 4beaffbda9 fix(app): show review diffs for non-git VCS backends (#46684)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:04:24 +10:00
opencode-agent[bot]andBrendonovich 21bbcc33a1 fix(app): restore uniform new session tab width (#46919)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-03 08:01:11 +08:00
Kit Langton 0b1dbaf621 refactor(client): edit streamed message targets directly (#46924) 2026-09-02 20:00:25 -04:00
Dax Raad 0da51e9274 fix(updates): retry transient artifact publication failures 2026-09-02 19:30:11 -04:00
Dax dea1f9cb7b fix(core): update plugin reload npm fixture (#46918) 2026-09-02 19:12:00 -04:00
Dax 969055918f feat(cli): use endpoint-provided update packages (#46910) 2026-09-02 23:01:27 +00:00
Kit Langton f269025416 fix(core): keep plugin activation stable across failures and refreshes (#46899) 2026-09-02 18:56:23 -04:00
Dax Raad 4643cacb4a fix(cli): retry Windows service smoke cleanup 2026-09-02 18:55:26 -04:00
Dax Raad 68a40e2d21 chore: remove package reservation script 2026-09-02 18:15:53 -04:00
Dax 57c02cd04b fix(tui): refresh plugin dialog after updates (#46911) 2026-09-02 17:59:18 -04:00
Dax Raad 46515df4a3 refactor(updates): use useragent event field 2026-09-02 17:51:55 -04:00
Dax Raad 51250e4346 feat(updates): include client IP in request events 2026-09-02 17:51:33 -04:00
Aiden Cline dbd47702b2 fix(core): tell plan agent to discuss plans instead of writing files by default (#46905) 2026-09-02 16:49:04 -05:00
Dax Raad a085bf62a4 feat(updates): log requests to the shared data lake 2026-09-02 17:25:02 -04:00
Luke Parker b605f355ca fix(core): share the models.dev snapshot across Locations (#46784) 2026-09-03 07:22:07 +10:00
opencode-agent[bot] 85e2b0a23a chore: update nix node_modules hashes 2026-09-02 21:21:54 +00:00
Kit Langton 3dc187bf3f refactor(client): share location resource reads (#46831) 2026-09-02 17:05:09 -04:00
Dax ed3259a9b7 refactor(plugin): centralize host resolution (#46901) 2026-09-02 17:04:15 -04:00
Kit Langton db09cc842e fix(util): stage npm installs under the cache directory's real path (#46887) 2026-09-02 16:40:45 -04:00
509 changed files with 16392 additions and 4859 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
if: github.ref_name == 'v2'
working-directory: packages/updates
run: bun run deploy
env:
+3 -4
View File
@@ -575,6 +575,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -585,6 +586,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -971,7 +973,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1045,6 +1046,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -1123,7 +1125,6 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -5171,8 +5172,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
}
}
+1 -2
View File
@@ -27,7 +27,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -93,7 +92,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
@@ -179,6 +177,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+6 -14
View File
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -508,7 +505,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
@@ -620,16 +616,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
// A delta for a block that is not open, whether it already stopped or never
// started, has nothing to attach to and is dropped.
const result = ToolStream.append(
state.tools,
index,
event.contentBlockDelta.contentBlockIndex,
event.contentBlockDelta.delta.toolUse.input,
"Bedrock Converse tool delta is missing its tool call",
)
if (ToolStream.isError(result)) return yield* result
if (!result) return [state, []] as const
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
@@ -668,7 +662,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -765,7 +758,6 @@ export const protocol = Protocol.make({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
+92 -155
View File
@@ -304,6 +304,11 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -316,6 +321,7 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -395,6 +401,7 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -416,22 +423,16 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -875,9 +876,6 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -921,12 +919,37 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
@@ -966,7 +989,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
@@ -991,13 +1014,13 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1010,20 +1033,16 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1036,16 +1055,15 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
},
},
events,
]
}
if (item && isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1055,7 +1073,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
@@ -1065,18 +1082,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1095,7 +1110,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
@@ -1148,13 +1163,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
item: NormalizedEvent["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1171,15 +1186,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
if (item.type === "message") {
const active = state.message?.id === item.id
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1195,8 +1205,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: active ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1204,36 +1213,22 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1244,14 +1239,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "reasoning") {
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
@@ -1278,53 +1271,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
const reasoningItems = { ...state.reasoningItems }
delete reasoningItems[item.id]
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [state, NO_EVENTS] satisfies StepResult
@@ -1334,21 +1288,17 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1415,12 +1365,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1460,20 +1407,16 @@ export const step = (state: ParserState, input: Event) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
event.item?.type === "reasoning" &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
@@ -1483,11 +1426,7 @@ export const step = (state: ParserState, input: Event) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1520,11 +1459,9 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -1537,7 +1474,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
terminal,
},
})
@@ -201,12 +201,11 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
+25
View File
@@ -6,11 +6,13 @@ import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/
import {
InvalidProviderOutputError,
InvalidRequestError,
UnsupportedOperationError,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderID,
type TextPart,
type ToolResultPart,
} from "../schema/index.js"
@@ -254,6 +256,29 @@ export const invalidRequest = (message: string, cause?: unknown) =>
reason: new InvalidRequestError({ message, cause }),
})
/**
* Canonical constructor for operations the selected route does not implement.
* Prefer this over `invalidRequest` when the failure is a missing route
* capability rather than a malformed caller input, so consumers can branch on
* `reason._tag` plus `reason.operation` instead of matching message text.
*/
export const unsupportedOperation = (input: {
readonly operation: string
readonly message: string
readonly provider?: ProviderID
readonly route?: string
readonly cause?: unknown
}) =>
new AIError({
reason: new UnsupportedOperationError({
operation: input.operation,
message: input.message,
provider: input.provider,
route: input.route,
cause: input.cause,
}),
})
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
export type Item = OpenResponses.OutputItem & {
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -27,8 +26,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const onDone: (
state: OpenResponses.ParserState,
+12 -6
View File
@@ -159,6 +159,17 @@ export const appendOrStart = <K extends StreamKey>(
return appendTool(tools, key, tool, delta.text)
}
/**
* Append argument text to a started tool. Returns `undefined` when no tool is
* open under `key`, for protocols that ignore deltas without a matching block.
*/
export const append = <K extends StreamKey>(tools: State<K>, key: K, text: string): AppendOutcome<K> | undefined => {
const current = tools[key]
if (!current) return undefined
if (text.length === 0) return { tools, tool: current, events: [] }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
/**
* Append argument text to a tool that must already have been started. This keeps
* protocols honest when their stream grammar promises a start event before any
@@ -170,12 +181,7 @@ export const appendExisting = <K extends StreamKey>(
key: K,
text: string,
missingToolMessage: string,
): AppendOutcome<K> | AIError => {
const current = tools[key]
if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
): AppendOutcome<K> | AIError => append(tools, key, text) ?? eventError(route, missingToolMessage)
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
+8 -4
View File
@@ -46,9 +46,12 @@ const adapter = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* ProviderShared.unsupportedOperation({
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -69,7 +72,8 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+6 -3
View File
@@ -585,9 +585,12 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
})
+16
View File
@@ -35,6 +35,21 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
},
) {}
/**
* A caller-requested operation the selected route does not implement, such as
* explicit compaction on a route without a compact endpoint. Detected locally
* before any network I/O, so unlike transport or provider-output failures it
* never carries HTTP context from a provider round-trip.
*/
export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOperationError>(
"AI.Error.UnsupportedOperation",
)("UnsupportedOperation", {
...ReasonFields,
operation: Schema.String,
provider: Schema.optional(ProviderID),
route: Schema.optional(RouteID),
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
@@ -107,6 +122,7 @@ export class UnknownProviderError extends Schema.TaggedError<UnknownProviderErro
export const AIErrorReason = Schema.Union([
InvalidRequestError,
UnsupportedOperationError,
NoRouteError,
AuthenticationError,
RateLimitError,
+25 -1
View File
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { fixedResponse } from "./lib/http.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
@@ -75,3 +77,25 @@ test("tagged content and event guards accept both checkpoint representations", (
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
testEffect(fixedResponse("")).effect(
"explicit compaction on a route without a compact endpoint fails with UnsupportedOperation",
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("fixture"),
prompt: "hello",
})
expect(LLMClient.canCompact(request)).toBe(false)
const error = yield* LLMClient.compact(
request as unknown as Parameters<typeof LLMClient.compact>[0],
).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") {
expect(error.reason.operation).toBe("compact")
expect(error.reason.provider).toBe("anthropic")
expect(error.reason.route).toBe("anthropic-messages")
}
}),
)
@@ -713,9 +713,10 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores late tool deltas after contentBlockStop", () =>
it.effect("ignores tool deltas without an open tool block", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 5, delta: { toolUse: { input: "{}" } } }],
[
"contentBlockStart",
{
@@ -745,27 +746,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects tool deltas without contentBlockStart", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse tool delta is missing its tool call",
})
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+8 -5
View File
@@ -125,12 +125,15 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
).effect("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
}),
)
@@ -130,16 +130,22 @@ for (const model of [
}),
],
})
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
for (const [candidate, tag] of [
[
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
"InvalidRequest",
],
[
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
model.provider === "xai" ? "UnsupportedOperation" : "InvalidRequest",
],
] as const) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe(tag)
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
@@ -361,8 +367,9 @@ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic comp
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("LLMClient.compact")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction")
}),
)
@@ -428,7 +435,9 @@ for (const model of [
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact")
}),
)
}
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
it.effect("closes implicit summary boundaries", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
it.effect("preserves done-only reasoning text and encryption", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
@@ -217,7 +206,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
it.effect("preserves non-empty done-only message content", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,17 +219,11 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -272,126 +255,121 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -436,21 +414,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -500,14 +463,15 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,23 +586,21 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -610,43 +608,6 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues an item-id-less tool call with only the new tool output", () =>
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2129,47 +2130,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2890,7 +2850,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item boundary events", () =>
it.effect("ignores duplicate item start events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2901,7 +2861,6 @@ describe("OpenAI Responses route", () => {
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
@@ -2921,25 +2880,6 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -3793,43 +3733,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -4017,7 +3920,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +3935,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -4053,37 +3957,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
+8
View File
@@ -24,6 +24,7 @@ import {
ToolResultValue,
TransportError,
UnknownProviderError,
UnsupportedOperationError,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
@@ -276,6 +277,12 @@ test("AI errors serialize diagnostics only on their typed reason", () => {
test("AI error reasons are tagged Errors with required messages", () => {
const reasons = [
new InvalidRequestError({ message: "Invalid request" }),
new UnsupportedOperationError({
message: "Unsupported operation",
operation: "compact",
provider: model.provider,
route: "fake-route",
}),
new NoRouteError({
message: "No route",
route: RouteID.make("missing"),
@@ -293,6 +300,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
]
expect(reasons.map((reason) => reason._tag)).toEqual([
"InvalidRequest",
"UnsupportedOperation",
"NoRoute",
"Authentication",
"RateLimit",
@@ -0,0 +1,18 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
const component = await mount("ui-split-button--open-in", { globals: { theme } })
const control = component.locator('[data-component="split-button-v2"]')
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).not.toHaveCSS("box-shadow", "none")
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
await component.getByRole("button", { name: "Open options" }).hover()
await expect(control).toHaveCSS("box-shadow", border)
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("box-shadow", border)
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
})
}
@@ -0,0 +1,47 @@
import { benchmark, expect } from "./benchmark"
import { openCommandPalette } from "../utils/command-palette"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const home of [false, true]) {
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
const { dialog, input } = await openCommandPalette(page, home)
const title = home ? "Open settings" : "Copy Session ID"
const query = home ? "open settings" : "copy session"
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
await input.evaluate((element, title) => {
element.addEventListener(
"input",
() => {
performance.mark("palette-input")
const observer = new MutationObserver(() => {
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
if (!selected?.textContent?.includes(title)) return
performance.measure("palette-result", "palette-input")
observer.disconnect()
})
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
},
{ once: true, capture: true },
)
}, title)
await input.fill(query)
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
"aria-selected",
"true",
)
const result = await page.evaluate(() =>
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
)
expect(result).toHaveLength(1)
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
})
}
@@ -11,6 +11,7 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -122,7 +123,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
settings?: Record<string, boolean | TimelineDetail>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block" })
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const palette = await openCommandPalette(page)
const path = `**/api/session/${paletteSession.id}`
await page.route(path, (route) => route.abort("failed"))
const requested = page.waitForRequest(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_failed_refresh",
created: 2,
type: "session.viewed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, idle: 2 },
},
])
}, paletteSession.id)
await requested
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await palette.input.fill("copy session")
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await palette.input.press("Escape")
await page.unroute(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_recovered_refresh",
created: 3,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 2, version: 1 },
data: { sessionID, title: "Recovered session" },
},
])
}, paletteSession.id)
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
expect(errors).toEqual([])
})
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const { dialog, input } = await openCommandPalette(page)
const release = Promise.withResolvers<void>()
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
await release.promise
await route.fallback()
})
await input.pressSequentially("copy session")
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
await expect(copy).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
expect(warnings).toEqual([])
release.resolve()
})
test("home commands do not wait for session search", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page, true)
const release = Promise.withResolvers<void>()
await page.route("**/api/session?*", async (route) => {
await release.promise
await route.fallback()
})
await input.fill("open settings")
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(page).toHaveURL("/settings")
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
release.resolve()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", async (route) => {
await files.promise
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
})
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
},
})
})
await input.fill("copy")
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
await expect(project).toBeVisible()
// Select a non-first command with the keyboard before remote results arrive.
await input.press("ArrowDown")
await expect(project).toHaveAttribute("aria-selected", "true")
files.resolve()
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
// File results are usable even while sessions are still pending.
sessions.resolve()
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
await input.fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
})
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
},
})
})
await input.fill("README")
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
await expect(file).toHaveAttribute("aria-selected", "true")
sessions.resolve()
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
await expect(file).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
test.use({ serviceWorkers: "block", video: "off" })
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page)
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: { data: [{ path: "fixture.txt", type: "file" }] },
}),
)
await palette.input.fill("fixture.txt")
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
const file = page.getByRole("tab", { name: /fixture\.txt/ })
await expect(file).toBeVisible()
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("complementary", { name: "Review and files" })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(file).toHaveCount(0)
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
expect(warnings).toEqual([])
})
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page, true)
await palette.input.press("Escape")
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("region", { name: "Recent sessions" })
.getByRole("button", { name: /Palette fixture session/ })
.click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.keyboard.press("ControlOrMeta+t")
await expect(page).toHaveURL(/\/new-session\?/)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.locator('[data-component="composer-editor"]').blur()
await page.keyboard.press("Control+l")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeFocused()
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
await dialog.getByRole("textbox").press("Escape")
await expect(dialog).toHaveCount(0)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
for (const count of [3, 4]) {
await page.getByRole("button", { name: "New session", exact: true }).click()
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
}
await page.setViewportSize({ width: 600, height: 800 })
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.setViewportSize({ width: 1280, height: 800 })
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.keyboard.press("ControlOrMeta+w")
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
expect(warnings).toEqual([])
})
@@ -92,6 +92,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
@@ -114,6 +116,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
@@ -67,6 +67,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -87,6 +88,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -25,6 +26,53 @@ for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page, { untitled: true })
const label = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tab-title"]'
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
)
await expect(label).toHaveText("Session")
const pending = await submitPending(page, mock)
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("pending-tab-title", {
body: await page.screenshot(),
contentType: "image/png",
})
await expect(label).toHaveText("Session")
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(label).toHaveText("Session")
if (viewport.name === "mobile") {
await label.click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
await tab.click()
await expect(drawer).toBeHidden()
}
mock.events.push({
id: "evt_generated_title",
type: "session.renamed",
created: Date.now(),
location: { directory: workspace },
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
data: { sessionID: pending.sessionID, title: "Generated session title" },
})
await expect(label).toHaveText("Generated session title")
})
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
@@ -335,6 +383,61 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
const events: OpenCodeEvent[] = []
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
const expanded =
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
await page.route("**/api/session/*/command", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
commands.push({ sessionID, body: route.request().postDataJSON() })
// The server owns command expansion; the client receives the expanded inbox item.
events.push({
id: "evt_workspace_review",
type: "session.inbox.enqueued",
created: Date.now(),
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
inboxID: "msg_workspace_review",
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({ status: 204, headers })
})
const editor = page.locator('[data-component="composer-editor"]')
await editor.fill("/review")
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(editor).toHaveText("/review")
const pending = await submitPending(page, mock, "/review latest commit")
await draftFollowUp(page)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect
.poll(() => commands)
.toEqual([
{
sessionID: pending.sessionID,
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
},
])
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
await expect(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
expect(mock.prompts).toEqual([])
await testInfo.attach("expanded-worktree-command", {
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
contentType: "image/png",
})
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
@@ -346,12 +449,16 @@ async function draftFollowUp(page: Page) {
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
async function openDraft(
page: Page,
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const events: OpenCodeEvent[] = []
const project = {
id: projectID,
worktree: directory,
@@ -378,6 +485,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
events: options?.events ?? (() => events.splice(0)),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
@@ -404,7 +512,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
const session = currentSession(
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
workspace,
)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
@@ -436,6 +547,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
}),
)
if (options?.command) {
await page.route("**/api/command?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [{ name: "review", description: "Review changes" }],
},
headers,
}),
)
}
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@@ -461,11 +583,11 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts, events }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
await page.locator('[data-component="composer-editor"]').fill(prompt)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
@@ -481,7 +603,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
@@ -30,9 +30,11 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -66,6 +68,15 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { createMockServerHandler } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
test.use({ serviceWorkers: "block" })
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
await mockServers(page)
await page.addInitScript(
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
// Both servers stay connected while the client hydrates their active-session snapshots.
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
const servers = new Map(
[sessionA, sessionB].map(
(current) =>
[
current === sessionA ? serverA : serverB,
createMockServerHandler({
directory: current.directory,
project: {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions: current === sessionB ? [current, childB] : [current],
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
provider: { all: [], connected: [], default: {} },
pageMessages: () => ({ items: [] }),
}),
] as const,
),
)
page.on("close", () => servers.forEach((server) => void server.dispose()))
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const server = servers.get(url.origin)
if (!server) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
if (directory && directory !== current.directory)
return route.fulfill({
status: 500,
json: { name: "InvalidDirectory" },
headers: { "access-control-allow-origin": "*" },
})
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
const body = route.request().postDataBuffer()
const response = await server.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
"href",
"#opencode-v2-icon-plus",
)
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
@@ -0,0 +1,189 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Locator } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTogglePosition"
const sessionID = "ses_review_toggle_position"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_toggle_position",
worktree: directory,
vcs: "git",
name: "review-toggle-position",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-toggle-position",
projectID: "proj_review_toggle_position",
directory,
title: "Review toggle position",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
})
for (const width of [1000, 1440]) {
for (const direction of ["ltr", "rtl"] as const) {
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const header = page.locator("[data-session-title]")
const panel = page.locator("#review-panel")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const closed = await toggle.boundingBox()
if (!closed) throw new Error("Review toggle bounds are unavailable")
const headerBox = await header.boundingBox()
if (!headerBox) throw new Error("Session header bounds are unavailable")
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
await expect
.poll(async () => {
const box = await panel.boundingBox()
if (!box) return false
return (
closed.x >= box.x &&
closed.x + closed.width <= box.x + box.width &&
closed.y >= box.y &&
closed.y + closed.height <= box.y + 52
)
})
.toBe(true)
await expect
.poll(async () => {
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box ? box.y + box.height / 2 : undefined
})
.toBe(closed.y + closed.height / 2)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
})
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const ptys: { id: string; title: string }[] = []
const removed: string[] = []
await page.route("**/api/pty**", async (route) => {
const path = new URL(route.request().url()).pathname
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
if (route.request().method() === "DELETE") {
removed.push(path.split("/").at(-1)!)
return route.fulfill({ status: 204 })
}
if (path.endsWith("/connect-token")) {
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
}
if (path === "/api/pty" && route.request().method() === "POST") {
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
ptys.push(pty)
return route.fulfill({ json: { location, data: pty } })
}
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
})
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Control+Backquote")
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
for (const number of [2, 3, 4]) {
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
}
await expect
.poll(async () => {
const tabs = await terminal.getByRole("tablist").boundingBox()
const button = await toggle.boundingBox()
if (!tabs || !button) return false
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
})
.toBe(true)
await expectTerminalControlsAligned(terminal, toggle)
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
expect(removed).toEqual(["pty_review_4"])
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const position = await toggle.boundingBox()
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expect
.poll(async () => {
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
const button = await toggle.boundingBox()
if (!actions || !button) return undefined
return actions.y + actions.height / 2 - (button.y + button.height / 2)
})
.toBe(0)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expectTerminalControlsAligned(terminal, toggle)
})
}
}
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
await expect
.poll(async () => {
const centers = await Promise.all(
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
button.locator("svg").evaluate((element) => {
const svg = element as SVGSVGElement
const path = svg.getBBox()
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
.y
}),
),
)
return centers[0]! - centers[1]!
})
.toBeCloseTo(0, 1)
}
@@ -23,7 +23,8 @@ for (const direction of ["ltr", "rtl"] as const) {
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const boxes = await Promise.all(
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
)
const [title, menu, sidebar, summary] = boxes
if (!title || !menu || !sidebar || !summary) return false
return direction === "ltr"
? Math.abs(title.x + title.width - menu.x) <= 1 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
.poll(async () => {
const boxes = await Promise.all(
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
control.boundingBox(),
),
)
const [icon, title, menu, sidebar, summary] = boxes
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toHaveAttribute("aria-expanded", "false")
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
const [button, menu] = await Promise.all([
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
}),
)
.toBeCloseTo(32, 0)
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
Math.abs(divider.right - menu.right) <= 0.5 &&
rows.every(
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
)
)
}),
)
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
@@ -67,6 +67,12 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -95,9 +101,7 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
})
session.directory = destination
await transport.send({
@@ -221,7 +225,8 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
})
return requests
}
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -16,6 +17,9 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -0,0 +1,327 @@
import { expect, test } from "@playwright/test"
import { dict } from "../../src/runtime/i18n/ar"
import en from "../../src/runtime/i18n/en"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ serviceWorkers: "block" })
for (const direction of ["ltr", "rtl"] as const) {
for (const workspace of [false, true]) {
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
const copy = direction === "rtl" ? dict : en
const directory = workspace
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
: fixture.directory
const project = {
...fixture.project,
name: workspace
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
: "Timeline project",
sandboxes: workspace ? [directory] : [],
icon: {
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
},
}
await mockOpenCodeServer(page, {
directory,
project,
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem(
"opencode.global.dat:language",
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
)
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
)
}, direction)
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const trigger = header.getByRole("button", { name: project.name, exact: true })
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
await expect(page.getByRole("tooltip")).toHaveText(project.name)
await trigger.click()
const menu = page.getByRole("menu", { name: project.name, exact: true })
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("tooltip")).toBeHidden()
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
await expect(menu).toHaveCSS("direction", direction)
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await expect(settings).toBeEnabled()
await expect
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(320)
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
await expect(label).toHaveCSS("text-overflow", "ellipsis")
await expect(label).toHaveCSS("white-space", "nowrap")
if (workspace) {
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
}
}
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const icons = menu.locator(
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
)
await expect(icons).toHaveCount(3)
await expect
.poll(async () => {
const [button, centers] = await Promise.all([
trigger.boundingBox(),
icons.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect()
return box.x + box.width / 2
}),
),
])
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
})
.toBe(true)
if (!workspace) await page.clock.install()
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
const item = menu.getByRole("menuitem", { name: text, exact: true })
const anchor = item.locator("..")
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
const content = item.locator(".session-project-link-content")
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
await anchor.hover()
await expect(content).toHaveCSS("mask-image", "none")
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
if (text === directory) {
await expect(openIcon).toHaveCSS("opacity", "0")
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
await expect
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect
.poll(async () => {
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
if (!row || !icon) return false
return (
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
)
})
.toBe(true)
}
await expect(label).toHaveCSS("cursor", "default")
await expect(anchor).toHaveCSS("cursor", "default")
const tooltip = page.getByRole("tooltip")
if (workspace) {
await expect(tooltip).toHaveText(text)
await expect(tooltip).toHaveCSS("white-space", "normal")
await expect
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(480)
await expect
.poll(() =>
tooltip
.getByText(text, { exact: true })
.evaluate(
(element) =>
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
),
)
.toBe(true)
await expect
.poll(async () => {
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
})
.toBe(true)
}
if (!workspace) {
await page.clock.runFor(500)
await expect(tooltip).toBeHidden()
}
await settings.hover()
await expect(tooltip).toBeHidden()
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
await expect(content).toHaveCSS("mask-image", "none")
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("Enter")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Space")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("ArrowDown")
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
for (const selected of [false, true]) {
if (selected) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
}
await trigger.click()
await expect(projectItem).toBeEnabled()
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
await projectItem.hover()
await expect(projectItem).not.toHaveCSS("background-color", background)
await projectItem.click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
await expect(menu).toBeHidden()
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
}
})
}
}
for (const state of ["closed", "unopened"] as const) {
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
const messages = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory,
project: { ...fixture.project, sandboxes: [directory] },
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
})
await page.setViewportSize({ width: 1440, height: 900 })
if (state === "closed") {
await installStressSessionTabs(page)
await page.goto("/")
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeEnabled()
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(projectRow).toHaveCount(0)
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
}
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
for (const loaded of [false, true]) {
if (loaded) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
})
}
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.route(
(url) => url.pathname === stressSessionHref(fixture.targetID),
async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
body: (await response.text()).replace(
'<div id="root"',
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
),
})
},
)
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
const arrow = path.locator('[data-slot="session-project-open-icon"]')
await expect(arrow).toHaveCount(1)
await expect
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
})
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -36,7 +37,9 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -68,7 +71,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -100,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -23,20 +24,23 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -46,6 +50,9 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -95,7 +102,9 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -110,7 +119,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -118,7 +127,13 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -141,28 +156,11 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -172,6 +170,9 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -49,6 +50,9 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -74,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
@@ -84,7 +88,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
await timeline.send(
compactionStarted({
@@ -161,27 +170,27 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -198,21 +207,25 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -249,7 +262,7 @@ for (const name of ["shell", "subagent"] as const) {
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -268,9 +281,9 @@ test("shows a badge for active background work", async ({ page }) => {
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await expect(summary).toContainText("1 background task running")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -281,6 +294,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -368,14 +382,22 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -14,7 +15,9 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage(),
assistantMessage([
@@ -34,6 +37,7 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -110,8 +114,11 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -127,6 +134,7 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,134 +5,48 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
})
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -161,17 +161,19 @@ test("keeps failed search calls and their error cards inside the collapsed stack
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-circle-exclamation",
"#opencode-v2-icon-outline-hexagonal-warning",
)
await expect
.poll(() =>
@@ -180,6 +182,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -74,21 +75,29 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
]),
],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
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()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -106,8 +115,6 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -7,11 +8,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("transitions shell and question through running error outcomes", async ({ page }) => {
test("keeps shell and question failures in their Used group", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -23,30 +24,46 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -77,9 +94,12 @@ test("preserves surviving grouped patch state when its first patch fails", async
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -99,23 +119,20 @@ test("preserves surviving grouped patch state when its first patch fails", async
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -148,7 +165,10 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -19,6 +20,9 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -51,7 +55,9 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -89,13 +95,8 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -133,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.locator('[data-component="session-background-hint"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
@@ -259,6 +260,7 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
@@ -27,11 +27,63 @@ test.beforeEach(async ({ page }) => {
})),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
})
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const binding = settings.locator('[data-keybind-id="tab.new"]')
await binding.click()
await expect(binding).toHaveText("Press keys")
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -87,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("about opens without waiting for contributors", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.fulfill({
json: [],
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "Link",
Link: `<${url}&page=1004>; rel="last"`,
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByText(/^Version /)).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await settings.getByRole("tab", { name: "About", exact: true }).click()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
await website.focus()
contributors.resolve()
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
await expect(website).toBeFocused()
})
test("about is available in the mobile settings menu", async ({ page }) => {
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
await page.setViewportSize({ width: 390, height: 844 })
const settings = page.getByTestId("settings-screen")
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await settings.getByRole("button", { name: "About", exact: true }).click()
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
})
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.abort("failed")
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
contributors.resolve()
await failed
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/tmp/settings-padding"
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_padding",
canonical: directory,
name: "Settings padding",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) =>
route.fulfill({ json: [] }),
)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeFocused()
})
for (const viewport of [
{ width: 1280, height: 720, bottom: false },
{ width: 900, height: 600, bottom: false },
{ width: 780, height: 600, bottom: false },
{ width: 390, height: 844, bottom: false },
{ width: 390, height: 844, bottom: true },
]) {
test.describe(`${viewport.width}px, ${viewport.bottom ? "bottom" : "top"} navigation`, () => {
test.use({ viewport: { width: 1280, height: 720 }, contextOptions: { reducedMotion: "reduce" } })
test("every settings page leaves room below its final content", async ({ page }) => {
await page.setViewportSize(viewport)
const settings = page.getByTestId("settings-screen")
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
if (viewport.bottom) {
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
await toggle.locator('[data-slot="switch-control"]').click()
await expect(toggle.getByRole("switch")).toBeChecked()
}
let current = "Preferences"
for (const name of [
"Preferences",
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Experimental",
"About",
]) {
if (viewport.width >= 816) await settings.getByRole("tab", { name, exact: true }).click()
if (viewport.width < 816) {
await settings.getByRole("button", { name: current, exact: true }).click()
await page.getByRole("menuitemradio", { name, exact: true }).click()
}
current = name
if (name === "About") await expect(panel.getByText("Released under the MIT License")).toBeVisible()
if (name !== "About") {
await expect(
panel.getByRole("heading", { name: name === "Shortcuts" ? "Keyboard shortcuts" : name, exact: true }),
).toBeVisible()
}
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThanOrEqual(1)
await expect
.poll(
() =>
panel.evaluate((el) => {
const body = el.querySelector(".settings-tab-body, .settings-about-content")!
return el.getBoundingClientRect().bottom - body.lastElementChild!.getBoundingClientRect().bottom
}),
{ message: `${name} bottom clearance` },
)
.toBeGreaterThanOrEqual(viewport.bottom ? 119.5 : 79.5)
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeLessThanOrEqual(1)
}
})
})
}
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ contextOptions: { reducedMotion: "reduce" }, colorScheme: "dark" })
for (const viewport of [
{ width: 1280, height: 720 },
{ width: 900, height: 600 },
{ width: 780, height: 600 },
{ width: 390, height: 844 },
]) {
test(`preferences scroll only inside the panel at ${viewport.width}x${viewport.height}`, async ({ page }) => {
const directory = "/tmp/settings-scroll"
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_scroll",
canonical: directory,
name: "Settings scroll",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
const panel = settings.getByRole("tabpanel")
const main = page.getByRole("main")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(settings).toBeFocused()
await page.setViewportSize(viewport)
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
// Wheel over the outer gutter must not move the entire settings screen.
await main.hover({ position: { x: 1, y: 200 } })
await page.mouse.wheel(0, 10000)
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect.poll(() => panel.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
await expect(main).toHaveJSProperty("scrollTop", 0)
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
await slider.scrollIntoViewIfNeeded()
await slider.click()
await page.keyboard.press("Home")
await expect(slider).toHaveValue("0")
await page.keyboard.press("ArrowRight")
await expect(slider).toHaveValue("1")
await expect(slider).toBeFocused()
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await expect(
settings.getByRole("group", { name: "Set placement and details for each activity category.", exact: true }),
).toBeVisible()
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThanOrEqual(1)
await expect(main).toHaveJSProperty("scrollTop", 0)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
})
}
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import pkg from "../../package.json" with { type: "json" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
@@ -9,30 +8,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab hugs its content", async ({ page }) => {
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, directory }) => {
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionID: sessionA.id, directory: sessionA.directory },
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -173,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
await expect(
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
@@ -182,7 +195,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -219,7 +232,163 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
test("appearance experimental settings control vertical tab details", async ({ page }) => {
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
...Array.from({ length: 24 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_scroll_${index}`,
})),
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(tabB).toBeInViewport({ ratio: 1 })
await expect
.poll(async () => {
const tab = await tabB.boundingBox()
const viewport = await scroll.boundingBox()
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
})
}
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, locale }) => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id, locale: profile.locale },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
await expect(sidebar).toHaveCSS("direction", profile.direction)
for (const row of [
{ action: "home", shortcut: "Ctrl+Alt+H" },
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
]) {
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect
.poll(() =>
hint.evaluate((element) => {
const button = element.closest("button")!
const row = button.getBoundingClientRect()
const hint = element.getBoundingClientRect()
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
}),
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
})
}
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -237,11 +406,14 @@ test("appearance experimental settings control vertical tab details", async ({ p
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
@@ -262,27 +434,33 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Experimental", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Appearance", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(layout).toHaveCount(0)
await settings.getByRole("button", { name: "Appearance", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
await expect(layout).toContainText("Vertical")
await expect(projectNameSwitch).toBeChecked()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
@@ -300,7 +478,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(layout).toContainText("Vertical")
})
@@ -78,9 +78,13 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+57
View File
@@ -0,0 +1,57 @@
import { expect, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "./mock-server"
import { APP_READY_TIMEOUT } from "./waits"
export const paletteSession = {
id: "ses_command_palette",
projectID: "proj_command_palette",
directory: "C:/OpenCode/CommandPalette",
title: "Palette fixture session",
time: { created: 1700000000000, updated: 1700000000000 },
}
export function captureConsoleWarnings(page: Page) {
const warnings: string[] = []
page.on("console", (message) => {
if (message.type() !== "warning" && message.type() !== "error") return
// This message comes from test isolation, not application code.
if (message.text() === "Service Worker registration blocked by Playwright") return
warnings.push(message.text())
})
return warnings
}
export async function openCommandPalette(page: Page, home = false) {
await mockOpenCodeServer(page, {
directory: paletteSession.directory,
project: {
id: paletteSession.projectID,
worktree: paletteSession.directory,
vcs: "git",
name: "command-palette",
time: paletteSession.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [paletteSession],
pageMessages: () => ({ items: [] }),
findFiles: () => [],
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
if (home) {
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
}
if (!home) {
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
}
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const input = dialog.getByRole("textbox")
await expect(input).toBeFocused()
await expect(dialog.getByRole("option")).not.toHaveCount(0)
return { dialog, input }
}
+2 -1
View File
@@ -26,7 +26,8 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+6 -1
View File
@@ -2,6 +2,11 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+24 -3
View File
@@ -47,6 +47,7 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -281,6 +282,24 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -556,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
@@ -673,6 +692,7 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -701,6 +721,7 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -749,9 +770,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+1
View File
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
+48 -2
View File
@@ -52,10 +52,12 @@ function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
prompt: async () => undefined,
command: async (value) => sent.resolve(value),
})
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
expect(request.delivery).toBe("steer")
})
test("captures commands before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
command: async (value) => {
requests.push(value)
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
{
sessionID: target.id,
command: "review",
text: "https://github.com/example/repo/pull/1",
files: [],
agents: [],
skills: [],
delivery: "steer",
},
])
})
test("does not run an empty shell command from hidden attachments", async () => {
const state = createMemoryComposerState().capture()
state.set([
+5 -4
View File
@@ -27,6 +27,7 @@ type ComposerSubmission = {
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
try {
const started =
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
}
function findCommand(session: ComposerSession, text: string) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
return
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
+15 -1
View File
@@ -3,7 +3,8 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
export function createHomeController() {
const layout = useLayout()
@@ -45,6 +46,18 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -105,6 +118,7 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,6 +14,7 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -79,6 +80,35 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,6 +37,8 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
+7
View File
@@ -57,6 +57,8 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -670,6 +672,11 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
const items = (query: string) => {
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
}
onCleanup(() => {
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
items={items}
sources={[sessions]}
highlight={highlight}
select={select}
close={() => dialog.close()}
@@ -21,7 +21,8 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -45,6 +46,7 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -172,7 +174,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
@@ -1,5 +1,6 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
+1
View File
@@ -134,6 +134,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
+1
View File
@@ -46,6 +46,7 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
+1
View File
@@ -44,6 +44,7 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
+1
View File
@@ -136,6 +136,7 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
+1
View File
@@ -136,6 +136,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
+62
View File
@@ -100,6 +100,7 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -675,11 +676,14 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -808,6 +812,10 @@ export const dict = {
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.channel.local": "Local",
"titlebar.channel.dev": "Dev",
"titlebar.channel.beta": "Beta",
"titlebar.toggleDebugTools": "Toggle debug tools",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -904,6 +912,25 @@ export const dict = {
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.tab.experimental": "Experimental",
"settings.experimental.description": "Try experimental features",
"settings.tab.about": "About",
"settings.about.version": "Version {{version}}",
"settings.about.devVersion": "development",
"settings.about.license": "Released under the MIT License",
"settings.about.writtenBy": "Written by",
"settings.about.illustratedBy": "Illustrated by",
"settings.about.and": "and",
"settings.about.otherContributor.one": "{{count}} other",
"settings.about.otherContributor.other": "{{count}} others",
"settings.about.firstPublished": "First published in Missouri, USA",
"settings.about.firstIllustrated": "First illustrated in London, England",
"settings.about.website": "www.opencode.ai",
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.tagline": "AI cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
@@ -931,6 +958,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
@@ -951,6 +979,40 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
+1
View File
@@ -40,6 +40,7 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
+1
View File
@@ -41,6 +41,7 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",

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