Compare commits

..
Author SHA1 Message Date
Brendan Allan 461c42f757 refactor(app): restore explicit persistence initial state 2026-09-01 17:41:49 +08:00
Brendan Allan 322bf226ba test(app): remove leaking UI context mocks 2026-09-01 16:44:14 +08:00
Brendan Allan 66ed350fea fix(app): preserve persisted state compatibility 2026-09-01 16:34:37 +08:00
Brendan Allan e65e1ecf59 refactor(app): simplify persistence schema helpers 2026-09-01 16:17:40 +08:00
Brendan Allan d40b64a6bd refactor(app): drive persisted state with Effect Schema 2026-09-01 15:19:59 +08:00
opencode-agent[bot]andBrendonovich 71779ae5de fix(app): scope pane visibility to tabs (#46508)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 12:21:42 +08:00
Aiden Cline 4a3e25beab feat(codemode): compact schema constraint comments (#46521) 2026-08-31 23:06:49 -05:00
opencode-agent[bot]andrekram1-node 23fde448ec test(cli): isolate Bun define cache (#46536)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-31 23:01:02 -05:00
opencode-agent[bot] 1137861188 chore: update nix node_modules hashes 2026-09-01 04:00:15 +00:00
Dax Raad 815d4ab9b4 fix(tui): scope connect dialog to location 2026-08-31 23:42:35 -04:00
Dax Raad 5d73a5789f feat(plugin): support live package updates 2026-08-31 23:35:21 -04:00
Aiden Cline df05945042 chore(ci): align Bun with the release runtime (#46524) 2026-08-31 21:48:35 -05:00
Aiden Cline 6a99898ef7 feat(core): register tool namespaces (#46487) 2026-08-31 21:29:19 -05:00
a40a87276a fix(tui): pin diff highlights query (#46518)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:06:43 -05:00
Luke Parker a20cbc394e feat(session-ui): preview images in read tool results (#46513) 2026-09-01 01:44:39 +00:00
Aiden Cline 8fda87614f feat(codemode): document numeric string and array constraints (#46510) 2026-08-31 20:44:07 -05:00
Kit Langton dffd95ce7c fix(codemode): reject Object.assign cycles (#46076) 2026-08-31 20:38:02 -04:00
opencode-agent[bot]andDavid Hill b0402f5a34 fix(session-ui): reduce inline code height (#46500)
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 08:26:46 +08:00
Kit Langton 54b00ec5fe test(tui): capture flushed Mini scrollback output (#46505) 2026-08-31 20:26:36 -04:00
Kit Langton 6dd1733bbf fix(core): preserve continuation across chained moves
Carry unfinished model work across consecutive Location handoffs without resetting the logical step allowance. Keep idle moves and queued prompt admission unchanged. Cover steered and queued second moves, preserved tool history, and durable event ordering.
2026-08-31 20:12:47 -04:00
opencode-agent[bot]andBrendonovich 663c2dc1ce fix(app): raise composer only in dark mode (#46503)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 08:12:09 +08:00
Kit Langton 01eda4c178 refactor(codemode): name only supported operations (#46082) 2026-08-31 20:00:13 -04:00
Kit Langton a6b49b3f74 fix(shell): preserve output from fast-exiting commands
Capture child stdout and stderr eagerly before lazy Effect readers attach. Preserve the bounded post-exit drain and process cleanup policies, with delayed-consumption and backpressure regressions.
2026-08-31 19:52:08 -04:00
opencode-agent[bot]andrekram1-node 5b2276666f test(tui): stop Windows image preview test crashes (#46479)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 18:31:47 -05:00
Kit Langton cc0cc59700 fix(ai): preserve done-only response messages (#46064) 2026-08-31 19:24:33 -04:00
Kit Langton 57a9decefe refactor(codemode): simplify input conflict detection (#46465) 2026-08-31 19:24:07 -04:00
Kit Langton c0220ddd8b refactor(codemode): reject unresolved intersections before rendering (#46468) 2026-08-31 19:23:52 -04:00
Kit Langton b31defc0a5 refactor(util): slice the final filename segment (#46466) 2026-08-31 19:23:35 -04:00
Kit Langton e7d42f83e6 refactor(core): slice model references at the first slash (#46467) 2026-08-31 19:23:21 -04:00
opencode-agent[bot]andBrendonovich db768c4886 refactor(app): share mobile drawer primitive (#46453)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 07:03:50 +08:00
Aiden Cline 9553187ba6 fix(core): allow patch file-to-directory replacements (#46476) 2026-08-31 16:24:04 -05:00
Aiden Cline d04257eeb4 feat(codemode): add inline namespace metadata (#46464) 2026-08-31 16:01:14 -05:00
opencode-agent[bot]andrekram1-node d68f425c17 feat(cli): port the upgrade command to v2 (#46183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 16:00:22 -05:00
Aiden Cline 49dd2cea34 refactor(ai): clarify responses adapters (#46469) 2026-08-31 15:36:57 -05:00
Simon Klee fac875dba0 mini v2 v2 (#46410) 2026-08-31 22:03:27 +02:00
Kit Langton 566ca864a0 refactor(util): reuse private patch buffers (#46459) 2026-08-31 15:11:54 -04:00
Dax Raad 5df9cecf03 fix(tui): remove plugin current marker 2026-08-31 14:31:03 -04:00
Dax Raad a68fe8a97d fix(tui): toggle plugin on dialog submit 2026-08-31 14:28:19 -04:00
Dax Raad c17c104827 fix(tui): toggle internal plugin controls 2026-08-31 14:25:06 -04:00
Dax Raad 5d4cc4a804 feat(tui): hide internal plugins by default 2026-08-31 14:25:06 -04:00
Kit Langton 1f04baa684 test: migrate fixture layer replacements (#46458)
Update the Core compile options and Server replacement values to the current LayerNode API. Preserve test expectations, replacement targets, and layer lifetimes.
2026-08-31 14:16:40 -04:00
Kit Langton 3e9b009642 feat(core): add session-aware instance selection (#46442) 2026-08-31 13:46:33 -04:00
Kit Langton 36ac35a7c8 refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00
Kit Langton 197d28e033 fix(tui): pin sidebar headings without scrollbar flashes (#46449)
Keep the title and workspace label above scrollable sidebar details. Disable the unused horizontal scrollbar and place the automatic vertical scrollbar in the reserved gutter so tab changes do not flash or shift the sidebar.
2026-08-31 13:45:55 -04:00
opencode-agent[bot]andkitlangton fcce2d7cc9 test(tui): await dialog text selection (#46143)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-08-31 13:44:00 -04:00
Dax Raad ec0dcb3da9 docs: improve build documentation discovery 2026-08-31 12:52:59 -04:00
Kit Langton afd7492018 fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00
Kit Langton 9517ff1054 fix(core): preserve active session continuation when moving 2026-08-31 12:14:45 -04:00
Kit Langton 1ced747051 fix(ai): handle message-less Gemini errors (#46069) 2026-08-31 12:14:31 -04:00
opencode-agent[bot]andDavid 43819dc376 fix(app): restore maskable pwa icons (#46434)
Co-authored-by: David <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 00:11:07 +08:00
Kit Langton e15dd8ecd3 fix(ai): require Bedrock message stop for finish (#46065) 2026-08-31 12:03:22 -04:00
Dax 6a38cacc1d docs: improve plugin guide readability (#46342) 2026-08-31 11:48:04 -04:00
Kit Langton d609752891 refactor(codemode): avoid merging root definitions twice (#46081) 2026-08-31 11:40:17 -04:00
Brendan Allan 5894e46688 fix(app): improve touch controls and standalone PWA relaunch (#46391) 2026-08-31 23:35:40 +08:00
Kit Langton 327dc809c5 refactor(core): reuse formatter file extension (#46080) 2026-08-31 11:30:57 -04:00
Kit Langton e9f7331516 refactor(core): reuse Markdown chunk byte counts (#46079) 2026-08-31 11:30:49 -04:00
Kit Langton 8be3ce8b6c refactor(util): reuse BOM-stripped text (#46078) 2026-08-31 11:30:40 -04:00
Kit Langton 30721b8b5d fix(server): await providers before catalog reads (#46066) 2026-08-31 11:24:56 -04:00
Kit Langton eb083cce63 fix(server): detach PTYs when sockets close (#46068) 2026-08-31 10:53:48 -04:00
1afb7c614e fix(app): raise active composer surface (#46401)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-31 22:47:49 +08:00
Kit Langton 56e773831c fix(ai): validate cache tail counts (#46067) 2026-08-31 10:46:18 -04:00
Brendan Allan 6b1ed3918a feat(app): refine mobile diff review and wrapping preferences (#46390) 2026-08-31 22:32:11 +08:00
opencode-agent[bot]andvimtor 1b3eb1138e fix(tui): queue autocompleted commands (#46414)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-08-31 19:52:29 +05:30
Brendan Allan 711a0a2da2 feat(app): add mobile session panels and detail drawers (#46389) 2026-08-31 14:04:14 +00:00
opencode-agent[bot]andnexxeln ac77cc46b8 fix(client): isolate shared event consumers (#46393)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-31 19:32:27 +05:30
opencode-agent[bot] 7197fdfb4e chore: update nix node_modules hashes 2026-08-31 13:40:50 +00:00
Brendan Allan 5a4914c670 feat(app): refine mobile shell home and settings (#46388) 2026-08-31 21:20:17 +08:00
Kit Langton 3a797bf6e4 fix(ai): validate canonical tool results (#46062) 2026-08-31 09:14:31 -04:00
opencode-agent[bot]andvimtor 33536da231 fix(core): commit undo before compaction (#46383)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-08-31 17:03:51 +05:30
Shoubhit Dash e56ceed32b Revert "fix(tui): surface subagent permissions and questions" (#46376) 2026-08-31 15:41:46 +05:30
Shoubhit Dash b2c7246134 fix(core): refresh git references on daily activity (#45575) 2026-08-31 15:36:37 +05:30
Shoubhit Dash 9c39e75ce2 fix(tui): surface subagent permissions and questions (#44976) 2026-08-31 14:38:05 +05:30
opencode-agent[bot]andBrendonovich 0dad76e618 fix(ui): prevent menu items from shrinking (#46353)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 16:00:55 +08:00
Luke Parker 90fb6562ce fix(shell): bound post-exit pipe draining on all platforms (#46085) 2026-08-31 16:53:35 +10:00
opencode-agent[bot]andBrendonovich 174d263890 fix(app): save session titles on blur and add tab context menu (#46113)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 13:07:32 +08:00
opencode-agent[bot]andBrendonovich 5ec29e7a87 refactor(desktop): use password-only server authentication (#45958)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 13:06:35 +08:00
Luke Parker 3c6b85acf7 fix(app): reveal pasted composer content with custom scrollbar (#46339) 2026-08-31 14:38:28 +10:00
Luke Parker 50e77f66fd fix(app): keep composer select all scoped to the editor (#46338) 2026-08-31 14:38:00 +10:00
Aiden Cline d484f070d1 fix(core): recover reads with non-breaking spaces (#45807) 2026-08-30 23:13:26 -05:00
Luke Parker 8890294bf0 fix(desktop): preserve Windows editing shortcuts (#46336) 2026-08-31 04:07:42 +00:00
Luke Parker a1925de0c1 fix(core): flush trailing stream chunks while providers pause (#46326) 2026-08-31 14:03:17 +10:00
Aiden Cline 24e826d06b fix(ai): validate Bedrock media data (#46333) 2026-08-30 22:58:58 -05:00
602 changed files with 22108 additions and 7438 deletions
+17
View File
@@ -54,6 +54,7 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -968,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -977,6 +979,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -1642,6 +1645,10 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -2876,6 +2883,8 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
@@ -5302,6 +5311,10 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5316,6 +5329,8 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
@@ -6630,6 +6645,8 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+2 -2
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+16 -20
View File
@@ -510,9 +510,10 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -692,12 +693,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
},
[],
@@ -705,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
usage,
},
[],
] as const
@@ -736,18 +731,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
if (!state.finishReason) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
...state.finishReason,
normalized,
},
usage: state.pendingFinish.usage,
usage: state.usage,
})
return events
}
@@ -771,7 +766,8 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
+11 -2
View File
@@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
+58 -43
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| HostedToolReplayItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface Extension {
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,6 +397,9 @@ export interface ParserState {
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>>
}
@@ -482,12 +485,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -507,17 +510,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return lowered
})
@@ -526,13 +529,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
"tool-result",
)
})
@@ -540,30 +543,33 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -571,13 +577,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -644,7 +650,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
: adapter.restoreHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -658,13 +664,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -677,11 +681,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
output: yield* lowerToolResultOutput(part, request, adapter),
})
}
}
@@ -733,28 +737,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -768,7 +772,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
// =============================================================================
@@ -951,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): 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]
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)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -969,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1085,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : 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
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1098,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1408,9 +1422,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1418,6 +1432,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
adapter,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Encoding, Schema } from "effect"
import type { MediaPart } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
@@ -57,6 +57,16 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
Effect.mapError((cause) =>
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
),
)
return Encoding.encodeBase64(bytes)
})
// Route by MIME. Known image/document formats lower into a typed block; anything
// else fails with a clear error instead of silently degrading to a malformed
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
@@ -66,8 +76,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
const media = ProviderShared.normalizeMedia(part)
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
@@ -75,8 +84,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
const media = ProviderShared.normalizeMedia(part)
return documentBlock(part.filename, documentFormat, media.base64)
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
+1 -6
View File
@@ -9,7 +9,6 @@ import {
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -56,11 +55,6 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
@@ -80,6 +74,7 @@ const toolResultValueSchema = Schema.Union([
}),
]).annotate({ identifier: "LLM.ToolResult" })
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
const isToolResultValue = Schema.is(toolResultValueSchema)
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
+1 -1
View File
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
Schema.Struct({ tail: Schema.Natural }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
@@ -631,10 +631,45 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1429,6 +1464,20 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects image media that is not valid base64", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1552,6 +1601,37 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects remote media URLs in tool results", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{
type: "file",
uri: "https://example.test/report.pdf",
mime: "application/pdf",
name: "report.pdf",
},
],
},
}),
],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -62,6 +62,62 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
@@ -82,6 +82,32 @@ 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", () => {
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
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,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
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: "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" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
+32
View File
@@ -21,6 +21,7 @@ import {
QuotaExceededError,
RateLimitError,
RouteID,
ToolResultValue,
TransportError,
UnknownProviderError,
Usage,
@@ -83,6 +84,37 @@ describe("llm schema", () => {
})
})
describe("ToolResultValue", () => {
test("uses the canonical schema guard", () => {
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
{ value: { type: "json", value: { ok: true } }, expected: true },
{ value: { type: "text", value: "done" }, expected: true },
{ value: { type: "error", value: "failed" }, expected: true },
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
{ value: { type: "content", value: "done" }, expected: false },
{ value: { type: "json" }, expected: false },
{ value: { type: "unknown", value: "done" }, expected: false },
]
for (const item of cases) {
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
expect(ToolResultValue.is(item.value)).toBe(item.expected)
}
})
test("accepts canonical results with extra fields", () => {
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
expect(
ToolResultValue.is({
type: "content",
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
metadata: { source: "tool" },
}),
).toBe(true)
})
})
describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
+27
View File
@@ -442,6 +442,33 @@ describe("LLMClient tools", () => {
}),
)
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
Effect.gen(function* () {
const malformed = { type: "content", value: [{ type: "text" }] }
const dynamic = Tool.make({
description: "Return caller-defined JSON.",
jsonSchema: { type: "object", properties: {} },
execute: () => Effect.succeed(malformed),
})
const dispatched = yield* ToolRuntime.dispatch(
{ dynamic },
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
)
expect(dispatched.result).toEqual({ type: "json", value: malformed })
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "dynamic",
result: { type: "json", value: malformed },
output: { structured: malformed, content: [] },
}),
])
}),
)
it.effect("executes tool calls for one step without looping by default", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
@@ -1,5 +1,45 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const text = await input.textContent()
for (let count = 0; count < 2; count++) {
await input.press("ControlOrMeta+a")
expect(
await input.evaluate((editor) => {
const selection = window.getSelection()
return {
text: selection?.toString(),
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
}
}),
).toEqual({ text, inside: true })
}
await page.keyboard.type("Replacement draft")
await expect(input).toHaveText("Replacement draft")
await expect(component.getByRole("status")).toHaveText("Ready")
if (draft === "mixed-attachments") {
await expect(component.getByAltText("layout.png")).toBeVisible()
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
}
})
}
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
@@ -8,6 +8,30 @@ story.beforeEach(async ({ mount }) => {
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
const content = root.locator("[data-timeline-virtual-content]")
await expect(content).toHaveCSS("visibility", "visible")
const gap = () =>
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
const viewport = element.closest("[data-scrollable]")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
})
await expect.poll(gap).toBe(16)
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect.poll(gap).toBe(16)
await page.setViewportSize({ width: 1280, height: 900 })
await expect.poll(gap).toBe(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(gap).toBe(16)
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"
import { expect, test, type Locator } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -65,11 +65,64 @@ for (const lines of [6000, 25000]) {
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
await expect(input).toBeFocused()
await expectCaretVisible(input)
const scroll = page.locator('[data-component="composer-scroll"]')
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
await page.keyboard.type("!")
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
await expectCaretVisible(input)
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
const bounds = await scroll.boundingBox()
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
await page.mouse.down()
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
await page.mouse.up()
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
await expect(input).toBeFocused()
await page.keyboard.press("ControlOrMeta+Home")
await page.keyboard.press("ControlOrMeta+End")
await expectCaretVisible(input)
})
}
async function expectCaretVisible(input: Locator) {
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
const caret = selection.getRangeAt(0).getBoundingClientRect()
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
}),
)
.toBe(true)
}
for (const width of [390, 1280]) {
for (const direction of ["ltr", "rtl"]) {
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 })
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const suffix = "\nExisting trailing content".repeat(100)
await input.fill("Before " + suffix)
await input.press("ControlOrMeta+Home")
await input.press("ArrowRight")
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
await expectCaretVisible(input)
await page.keyboard.type("!")
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
await expectCaretVisible(input)
})
}
}
for (const text of [
"single line <b> &amp;",
"first\nsecond",
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,10 +131,8 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -144,6 +142,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
}
@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: (path) =>
path
? []
: ["first.ts", "second.ts"].map((name) => ({
name,
path: name,
absolute: `${fixture.directory}/${name}`,
type: "file",
ignored: false,
})),
fileContent: (path) => `contents:${path}`,
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
await expect
.poll(() =>
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
const header = element.closest('[data-slot="session-mobile-files-header"]')!
const separator = getComputedStyle(element, "::before")
return {
height: separator.height,
fullWidth: parseFloat(separator.width) === header.clientWidth,
start: separator.insetInlineStart,
bottom: separator.bottom,
}
}),
)
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files.locator('[data-column-number="1"]').click()
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
await expect(editor.getByRole("textbox")).toBeVisible()
for (const width of [390, 700]) {
await page.setViewportSize({ width, height: 844 })
await expect
.poll(async () => {
const panel = await files.boundingBox()
const comment = await editor.boundingBox()
if (!panel || !comment) return false
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
})
.toBe(true)
}
await editor.getByRole("textbox").fill("Full-width file comment")
await editor.getByRole("button", { name: "Comment", exact: true }).click()
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
await expect(comment).toContainText("Full-width file comment")
await expect
.poll(async () => {
const panel = await files.boundingBox()
const card = await comment.boundingBox()
if (!panel || !card) return false
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
})
.toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
await files.getByRole("button", { name: "All files", exact: true }).click()
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
await files.getByRole("option", { name: "second.ts", exact: true }).click()
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
await expect
.poll(() =>
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
const header = element.closest('[data-slot="session-mobile-files-header"]')!
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
}),
)
.toBe(true)
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
await files.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(openTabs.getByRole("tab")).toHaveCount(0)
})
}
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const loading = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
fileContent: async (path) => {
if (path === "second.ts") await loading.promise
return `contents:${path}`
},
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
file,
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
})),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
const files = page.locator('[data-slot="session-mobile-files"]')
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
const header = diff.getByRole("button", { name: file, exact: true })
await expect(header).toBeEnabled()
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
await diff.getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
if (file === "second.ts") loading.resolve()
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
})
@@ -0,0 +1,138 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, {
vcsDiff: [
{
file: "added.ts",
status: "added",
additions: 1,
deletions: 0,
patch:
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
},
{
file: "removed.ts",
status: "deleted",
additions: 0,
deletions: 1,
patch:
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
},
{
file: "modified.ts",
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
},
],
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
const review = page.locator('[data-component="session-review"]')
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-top-width",
"0px",
)
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-bottom-width",
"0px",
)
for (const change of [
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
]) {
const item = review.locator(`[data-file="${change.file}"]`)
const trigger = item.getByRole("button", { name: change.file, exact: true })
const summary = item.locator('[data-slot="session-review-change-summary"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
await expect(summary).toHaveCount(0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (change.file === "modified.ts") {
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
}
await expect(summary).toBeVisible()
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCount(0)
}
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
const modified = review.locator('[data-file="modified.ts"]')
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
await expect
.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(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true)
await modified.locator("[data-code]").evaluate((element) => {
element.scrollLeft = 100
})
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
.toBeGreaterThan(0)
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await page.keyboard.press("Control+,")
await expect(wrap).not.toBeChecked()
await wrapControl.click()
await expect(wrap).toBeChecked()
await expect
.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(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
await expect
.poll(async () => {
const button = await openFile.boundingBox()
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
if (!button || !summary) return false
return direction === "ltr"
? button.x > summary.x + summary.width / 2
: button.x + button.width < summary.x + summary.width / 2
})
.toBe(true)
await openFile.click()
await expect(
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
).toHaveAttribute("aria-selected", "true")
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files).toHaveAttribute("data-browsing", "false")
})
}
@@ -0,0 +1,186 @@
import { expect, test } from "@playwright/test"
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"
for (const position of ["top", "bottom"] as const) {
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
})
await installStressSessionTabs(page)
await page.addInitScript(
(position) =>
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
position,
)
await page.route("**/api/pty*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: {
id: "pty_mobile_views",
title: "Terminal 1",
command: "sh",
args: [],
cwd: fixture.directory,
status: "running",
pid: 1,
},
},
}),
)
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
},
}),
)
await page.goto(stressSessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
await expect(drawer).toHaveAttribute("data-open", "")
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await page.getByRole("button", { name: "Back to app", exact: true }).click()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
await expect(usage).toHaveCSS("padding-inline-start", "16px")
await expect(usage).toHaveCSS("padding-inline-end", "16px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
await expect(panel.locator("textarea")).toBeEditable()
await expect(panel).toHaveCount(1)
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await expect(message).toBeVisible()
await expect(panel).toBeHidden()
await expect(panel).toHaveAttribute("inert", "")
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await page.goto(stressSessionHref(fixture.sourceID))
await expect(picker).toHaveText("Session")
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
await page.setViewportSize({ width: 1280, height: 900 })
await expect(picker).toBeHidden()
await expect(page.locator("[data-session-title]")).toBeVisible()
})
}
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
const projects = page.getByRole("button", { name: "Projects", exact: true })
await projects.click()
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
await expect(picker).toBeHidden()
await expect(projects).toContainText(fixture.project.name)
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
await expect(help).toBeVisible()
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
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(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
await expect(settings).toBeVisible()
await menu.click()
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
await expect
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.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(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
await page.mouse.down()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
await expect(overlay).toHaveCount(0)
await expect(more).toBeFocused()
}
})
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
source: { type: "package", target: id },
state: { status: "active" },
features: { server: true },
})),
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
await expect(changes).toHaveAttribute("aria-selected", "true")
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,37 +18,72 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -102,7 +137,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -114,8 +149,19 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -32,12 +32,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
const username = editor.getByPlaceholder("username")
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
const password = editor.getByPlaceholder("password")
await username.click()
await expect(username).toBeFocused()
await username.fill("kit")
await expect(username).toHaveValue("kit")
await name.click()
await expect(name).toBeFocused()
await name.fill("Remote")
await expect(name).toHaveValue("Remote")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("secret")
@@ -126,6 +126,10 @@ for (const create of [false, true]) {
json: [
{ directory: canonical, strategy: null },
{ directory: "/projects/existing-worktree", strategy: "git" },
...Array.from({ length: 20 }, (_, index) => ({
directory: `/projects/worktree-${index}`,
strategy: "git",
})),
],
headers,
})
@@ -161,7 +165,9 @@ for (const create of [false, true]) {
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
listing.resolve()
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
await expect(existing).toBeVisible()
await expect(existing).toHaveCSS("height", "28px")
await page.keyboard.press("Escape")
await expect(page.getByRole("menu")).toHaveCount(0)
listing = Promise.withResolvers<void>()
@@ -0,0 +1,130 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const id = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === id)
const payload: unknown = route.request().postDataJSON()
if (
!session ||
!payload ||
typeof payload !== "object" ||
!("title" in payload) ||
typeof payload.title !== "string"
)
throw new Error("Invalid rename request")
session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.goto("/")
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
for (const commit of ["Enter", "blur", "click outside"]) {
test(`saves the session heading on ${commit}`, async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
})
}
test("cancels the session heading with Escape", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Discard this title")
await input.press("Escape")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("keeps the draft when saving the session heading fails", async ({ page }) => {
await page.route("**/api/session/*/rename", (route) =>
route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }),
)
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Retry this title")
await input.press("Tab")
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(input).toBeEnabled()
await expect(input).toHaveValue("Retry this title")
await expect(
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
).toBeVisible()
})
test("does not save an empty session heading", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill(" ")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("renames and closes the session tab from its context menu", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
await renamed.click({ button: "right" })
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
await expect(renamed).toBeHidden()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
})
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
source: { type: "package", target: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -141,7 +141,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -89,26 +89,31 @@ test("cramped tabs only show the close button for the active tab", async ({ page
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
for (const direction of ["ltr", "rtl"]) {
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await page.setViewportSize({ width: 450, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
await expect(tabA).toBeVisible()
await page.setViewportSize({ width: 1280, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
await page.setViewportSize({ width: 450, height: 720 })
await page.getByRole("button", { name: "Tabs", exact: true }).click()
}
})
@@ -202,28 +207,28 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(version).toBeInViewport()
await expect(version).toHaveCSS("direction", "ltr")
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", 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="titlebar-tabs"]')
.locator('[data-slot="mobile-tabs-drawer"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
@@ -242,7 +247,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -259,7 +264,8 @@ test("vertical tab preference falls back to horizontal on mobile", async ({ page
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator('[data-slot="titlebar-tabs"]')
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: terminalStorageKey(), ptyID },
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
)
}
@@ -16,10 +16,9 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -120,7 +133,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -132,8 +145,20 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
+6
View File
@@ -8,6 +8,12 @@ export async function expectAppVisible(locator: Locator) {
}
export async function expectSessionTitle(page: Page, title: string) {
if ((page.viewportSize()?.width ?? 1280) < 768) {
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
await expectAppVisible(trigger)
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
return
}
await expectAppVisible(page.getByRole("heading", { name: title }))
}
+2 -2
View File
@@ -9,13 +9,13 @@
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
}
],
"theme_color": "#080808",
+1
View File
@@ -56,6 +56,7 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -11,12 +11,6 @@ beforeAll(async () => {
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
+5 -17
View File
@@ -10,14 +10,9 @@ import { createScopedCache } from "@/runtime/server/scoped-cache"
import { uuid } from "@/runtime/persistence/uuid"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { CommentStore, type LineComment } from "./schema"
export type LineComment = {
id: string
file: string
selection: SelectedLineRange
comment: string
time: number
}
export type { LineComment } from "./schema"
type CommentFocus = { file: string; id: string }
@@ -37,10 +32,6 @@ function decodeSessionKey(key: string) {
}
}
type CommentStore = {
comments: Record<string, LineComment[]>
}
function aggregate(comments: Record<string, LineComment[]>) {
return Object.keys(comments)
.flatMap((file) => comments[file] ?? [])
@@ -179,12 +170,9 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
}
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments"),
createStore<CommentStore>({
comments: {},
}),
)
const [store, setStore, _, ready] = persisted(Persist.serverScoped(scope, dir, id, "comments"), CommentStore, {
comments: {},
})
const session = createCommentSessionState(store, setStore)
return {
@@ -1,3 +1,7 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+26 -4
View File
@@ -9,6 +9,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
@@ -53,6 +54,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
const state = props.controller.state
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -145,7 +147,14 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
</Show>
<div class="relative min-h-[60px]">
<ScrollView
data-component="composer-scroll"
class="min-h-[60px] max-h-[180px]"
viewportRef={(element) => {
viewport = element
element.tabIndex = -1
}}
>
<div
ref={(element) => {
editor = element
@@ -162,7 +171,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
@@ -190,7 +199,20 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onPaste={(event) => {
props.controller.onPaste(event)
// Programmatic multiline insertion does not reliably reveal the caret.
requestAnimationFrame(() => {
const selection = window.getSelection()
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
if (!editor.contains(selection.anchorNode)) return
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
@@ -206,7 +228,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
</div>
</Show>
</div>
</ScrollView>
<div class="flex h-11 items-center px-2">
<div
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -58,7 +60,11 @@ describe("Composer history", () => {
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
expect(
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
entries: [text("stored")],
}),
).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
+3 -15
View File
@@ -1,24 +1,12 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export const MAX_HISTORY = 100
export type PromptHistoryComment = {
id: string
path: string
selection: SelectedLineRange
comment: string
time: number
origin?: "review" | "file"
preview?: string
}
export type PromptHistoryEntry = {
prompt: Prompt
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = PromptHistoryEntry
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+9 -21
View File
@@ -1,4 +1,4 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
@@ -8,28 +8,14 @@ import {
type PromptHistoryStoredEntry,
} from "./entry"
import { clonePrompt } from "../prompt-parts"
import { PromptHistoryState } from "../schema"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
export function upgradeHistoryState(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
const entries = value.entries
if (!Array.isArray(entries)) return value
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
}),
}
}
type PromptHistoryState = typeof PromptHistoryState.Type
function createComposerHistoryStore(
normal: Store<PromptHistoryState>,
@@ -51,12 +37,14 @@ function createComposerHistoryStore(
export function createComposerHistory() {
const [normal, setNormal, normalInit] = persisted(
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
Persist.prompt(Persist.global("prompt-history")),
PromptHistoryState,
{ entries: [] },
)
const [shell, setShell, shellInit] = persisted(
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
Persist.prompt(Persist.global("prompt-history-shell")),
PromptHistoryState,
{ entries: [] },
)
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
return {
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import {
CommentStore,
ComposerStore,
DEFAULT_PROMPT,
PromptHistoryState,
type TextPart,
type ImageAttachmentPart,
type FileAttachmentPart,
type LineComment,
} from "./schema"
const text: TextPart = { type: "text", content: "hello", start: 0, end: 5 }
const image: Omit<ImageAttachmentPart, "blob"> = {
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
}
const comment = { id: "comment", path: "src/app.ts", selection: { start: 1, end: 2 }, comment: "note", time: 1 }
describe("composer persistence schemas", () => {
test("defaults missing or invalid fields independently and normalizes the cursor", () => {
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)
expect(decode({})).toEqual({ prompt: DEFAULT_PROMPT, context: { items: [] } })
const value = decode({
prompt: [null, { type: "unknown" }],
cursor: -1,
mode: "unknown",
model: { providerID: 42, modelID: "model" },
retry: { id: "bad", agent: "build", providerID: "provider", modelID: "model" },
context: { items: [{ type: "file", path: "src/app.ts", commentID: "note", key: "stale" }, null] },
})
expect(value.prompt).toEqual(DEFAULT_PROMPT)
expect(value.cursor).toBe(0)
expect(value.mode).toBeUndefined()
expect(value.model).toBeUndefined()
expect(value.retry).toBeUndefined()
expect(value.context.items).toEqual([
{ type: "file", path: "src/app.ts", commentID: "note", key: "file:src/app.ts:undefined:undefined:c=note" },
])
expect(decode({ prompt: false, cursor: Infinity, context: null })).toEqual({
prompt: DEFAULT_PROMPT,
context: { items: [] },
})
const first = decode({})
first.prompt[0] = { type: "text", content: "changed", start: 0, end: 7 }
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
})
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
text,
{ type: "agent", content: "@build", start: 5, end: 11, name: "build" },
{ type: "skill", content: "@effect", start: 11, end: 18, id: "effect", name: "Effect" },
{ type: "agent", content: "@broken", start: 18, end: 25, name: 42 },
{
type: "file",
path: "src/app.ts",
content: "@src/app.ts",
start: 18,
end: 29,
selection: { startLine: "broken" },
mime: 42,
filename: "app.ts",
source: { type: "invalid" },
},
],
model: { providerID: "provider", modelID: "model", variant: null },
retry: { id: "msg_retry", agent: "build", providerID: "provider", modelID: "model", variant: false },
})
expect(value.prompt.map((part) => part.type)).toEqual(["text", "agent", "skill", "file"])
expect(value.prompt[3]).toEqual({
type: "file",
path: "src/app.ts",
content: "@src/app.ts",
start: 18,
end: 29,
filename: "app.ts",
})
expect(value.model?.variant).toBeNull()
expect(value.retry).toEqual({
id: SessionMessage.ID.make("msg_retry"),
agent: "build",
providerID: "provider",
modelID: "model",
})
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(Schema.encodeSync(ComposerStore)(value)),
).toEqual(value)
})
test("preserves file source variants through canonical round trips", () => {
const sourceText = { value: "@source", start: 0, end: 7 }
const sources: NonNullable<FileAttachmentPart["source"]>[] = [
{ type: "file", path: "src/app.ts", text: sourceText },
{ type: "resource", clientName: "docs", uri: "docs://example", text: sourceText },
{
type: "symbol",
path: "src/app.ts",
name: "App",
kind: 1,
range: { start: { line: 1, character: 0 }, end: { line: 2, character: 1 } },
text: sourceText,
},
]
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: sources.map((source) => ({
type: "file",
path: "src/app.ts",
content: "@source",
start: 0,
end: 7,
source,
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 1 },
})),
})
expect(value.prompt).toHaveLength(3)
expect(value.prompt.map((part) => part.type === "file" && part.source)).toEqual(sources)
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(Schema.encodeSync(ComposerStore)(value)),
).toEqual(value)
})
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
{ ...image, dataUrl: "data:image/png;base64,YQ==", sourcePath: "/image.png" },
{ ...image, blob: { id: "data:image/png;base64,Yg==" } },
{ ...image, blob: { id: "hash", url: "blob:hydrated" } },
{ ...image, blob: { id: "missing" } },
{ ...image, blob: { id: "bad", url: "https://example.com/image.png" } },
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(3)
expect(value.prompt[0]).toEqual({
...image,
sourcePath: "/image.png",
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
})
const encoded = Schema.encodeSync(ComposerStore)(value)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(encoded),
).toEqual(value)
})
test("migrates legacy history arrays and recovers entries, parts, and comments independently", () => {
const value = Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
entries: [
[text, null, { ...image, dataUrl: "data:image/png;base64,YQ==" }],
null,
{},
{ prompt: false, comments: [] },
{ prompt: [text, { type: "invalid" }], comments: [comment, { ...comment, selection: null }, false] },
{ prompt: [text], comments: false },
],
})
expect(value.entries).toHaveLength(3)
expect(value.entries[0].prompt).toHaveLength(2)
expect(value.entries[0].comments).toEqual([])
expect(value.entries[1]).toEqual({ prompt: [text], comments: [comment] })
expect(value.entries[2]).toEqual({ prompt: [text], comments: [] })
const encoded = Schema.encodeSync(PromptHistoryState)(value)
expect(encoded.entries?.every((entry) => !Array.isArray(entry))).toBe(true)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))(encoded)).toEqual(
value,
)
expect(
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({ entries: "invalid" }),
).toEqual({ entries: [] })
})
test("recovers comments per file and entry without discarding healthy siblings", () => {
const line: LineComment = {
id: "comment",
file: "src/app.ts",
selection: { start: 1, end: 2, side: "additions", endSide: "deletions" },
comment: "note",
time: 1,
}
const value = Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({
comments: {
"src/app.ts": [line, null, { ...line, time: "bad" }, { ...line, selection: { start: 2, end: 4, side: "bad" } }],
"broken.ts": { invalid: true },
"healthy.ts": [{ ...line, file: "healthy.ts" }],
},
})
expect(value.comments["src/app.ts"]).toEqual([line, { ...line, selection: { start: 2, end: 4 } }])
expect(value.comments["broken.ts"]).toEqual([])
expect(value.comments["healthy.ts"]).toEqual([{ ...line, file: "healthy.ts" }])
expect(
Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))(
Schema.encodeSync(CommentStore)(value),
),
).toEqual(value)
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({})).toEqual({
comments: {},
})
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({ comments: [] })).toEqual(
{ comments: {} },
)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { Schema, SchemaGetter } from "effect"
import { checksum } from "@opencode-ai/util/encode"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
const PartBase = {
content: Schema.String,
start: Schema.Number,
end: Schema.Number,
}
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
const FilePartSource = Schema.Union([
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
Schema.Struct({
type: Schema.Literal("symbol"),
text: SourceText,
path: Schema.String,
range: Schema.Struct({ start: Position, end: Position }),
name: Schema.String,
kind: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("resource"), text: SourceText, clientName: Schema.String, uri: Schema.String }),
])
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
export type TextPart = typeof TextPart.Type
export const FileAttachmentPart = Persistence.struct({
type: Schema.Literal("file"),
...PartBase,
path: Schema.String,
selection: Persistence.optional(FileSelection),
mime: Persistence.optional(Schema.String),
filename: Persistence.optional(Schema.String),
url: Persistence.optional(Schema.String),
source: Persistence.optional(FilePartSource),
})
export type FileAttachmentPart = typeof FileAttachmentPart.Type
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
export type AgentPart = typeof AgentPart.Type
export const SkillPart = Persistence.struct({
type: Schema.Literal("skill"),
...PartBase,
id: Skill.ID,
name: Skill.Name,
})
export type SkillPart = typeof SkillPart.Type
const ImageFields = {
type: Schema.Literal("image"),
id: Schema.String,
filename: Schema.String,
sourcePath: Persistence.optional(Schema.String),
mime: Schema.String,
}
const Image = Persistence.struct({
...ImageFields,
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
})
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
Schema.Struct({ id: Persistence.optional(Schema.String), url: Persistence.optional(Schema.String) }),
),
dataUrl: Persistence.optional(Schema.String),
}).pipe(
Schema.decodeTo(Schema.toType(Image), {
decode: SchemaGetter.transform((value) => {
const id = value.blob?.id ?? value.dataUrl ?? ""
const url = value.blob?.url
return {
type: value.type,
id: value.id,
filename: value.filename,
sourcePath: value.sourcePath,
mime: value.mime,
blob: {
id,
url: url?.startsWith("blob:") || url?.startsWith("data:") ? url : id.startsWith("data:") ? id : "",
},
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
export const PromptModel = Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
export type PromptModel = typeof PromptModel.Type
export const FileContextItem = Persistence.struct({
type: Schema.Literal("file"),
path: Schema.String,
selection: Persistence.optional(FileSelection),
comment: Persistence.optional(Schema.String),
commentID: Persistence.optional(Schema.String),
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
export type ContextItem = FileContextItem
export function contextItemKey(item: ContextItem) {
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
encode: SchemaGetter.transform((item) => item),
}),
)
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const ComposerStore = Persistence.struct({
prompt: Prompt.pipe(
Schema.decode({
decode: SchemaGetter.transform((prompt) =>
prompt.length ? prompt : DEFAULT_PROMPT.map((part) => ({ ...part })),
),
encode: SchemaGetter.transform((prompt) => prompt),
}),
),
cursor: Persistence.optional(
Schema.Finite.pipe(
Schema.decode({
decode: SchemaGetter.transform((cursor) => Math.max(0, cursor)),
encode: SchemaGetter.transform((cursor) => cursor),
}),
),
),
model: Persistence.optional(PromptModel),
mode: Persistence.optional(Schema.Literals(["normal", "shell"])),
retry: Persistence.optional(
Schema.Struct({
id: SessionMessage.ID,
agent: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
variant: Persistence.optional(Schema.String),
}),
),
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
})
export type ComposerStore = typeof ComposerStore.Type
export const LineComment = Persistence.struct({
id: Schema.String,
file: Schema.String,
selection: SelectedLineRange,
comment: Schema.String,
time: Schema.Number,
})
export type LineComment = typeof LineComment.Type
export const CommentStore = Persistence.struct({
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
})
export type CommentStore = typeof CommentStore.Type
export const PromptHistoryComment = Persistence.struct({
id: Schema.String,
path: Schema.String,
selection: SelectedLineRange,
comment: Schema.String,
time: Schema.Number,
origin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type PromptHistoryComment = typeof PromptHistoryComment.Type
// History entries require a prompt array; only its individual parts recover.
const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(ContentPart), () => undefined)).pipe(
Schema.decodeTo(Schema.toType(Prompt), {
decode: SchemaGetter.transform((parts) => parts.filter((part) => part !== undefined)),
encode: SchemaGetter.transform((parts) => parts),
}),
)
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
Schema.decodeTo(Schema.toType(HistoryEntry), {
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
encode: SchemaGetter.transform((entry) => entry),
}),
)
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+8 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Skill } from "@opencode-ai/schema/skill"
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
import { Schema, Option } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { createMemoryComposerState, DEFAULT_PROMPT } from "./state"
import { ComposerStore } from "./schema"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
@@ -29,7 +32,9 @@ describe("prompt state initialization", () => {
})
test("parses persisted state into one trusted current shape", () => {
const parsed = parseComposerStore({
const parsed = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
{ type: "text", content: "hello", start: 0, end: 5 },
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
@@ -105,6 +110,6 @@ describe("prompt state initialization", () => {
],
},
})
expect(parseComposerStore("not an object")).toBeUndefined()
expect(Option.isNone(Schema.decodeUnknownOption(ComposerStore)("not an object"))).toBe(true)
})
})
+33 -328
View File
@@ -1,127 +1,41 @@
import { checksum } from "@opencode-ai/util/encode"
import { batch, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/workspaces/files/model"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { clonePrompt } from "./prompt-parts"
import {
ComposerStore,
DEFAULT_PROMPT,
contextItemKey,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
} from "./schema"
interface PartBase {
content: string
start: number
end: number
}
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
ComposerStore,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
SkillPart,
TextPart,
} from "./schema"
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface SkillPart extends PartBase {
type: "skill"
id: Skill.ID
name: Skill.Name
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
blob: BlobReference
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | SkillPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export type ComposerStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
mode?: "normal" | "shell"
retry?: {
id: SessionMessage.ID
agent: string
providerID: string
modelID: string
variant?: string
}
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
@@ -147,16 +61,14 @@ function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
}
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
const target =
"draftID" in scope
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
return { ...target, migrate: parseComposerStore }
return "draftID" in scope
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
@@ -172,203 +84,6 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
}
}
export function parseComposerStore(value: unknown): ComposerStore | undefined {
if (!record(value)) return
const prompt = Array.isArray(value.prompt) ? value.prompt.flatMap(parsePart) : []
const context = record(value.context) && Array.isArray(value.context.items) ? value.context.items : []
const model = parseModel(value.model)
const retry = parseRetry(value.retry)
return {
prompt: prompt.length ? prompt : clonePrompt(DEFAULT_PROMPT),
...(typeof value.cursor === "number" && Number.isFinite(value.cursor) ? { cursor: Math.max(0, value.cursor) } : {}),
...(model ? { model } : {}),
...(value.mode === "normal" || value.mode === "shell" ? { mode: value.mode } : {}),
...(retry ? { retry } : {}),
context: {
items: context.flatMap((item) => {
const parsed = parseContextItem(item)
return parsed ? [{ ...parsed, key: contextItemKey(parsed) }] : []
}),
},
}
}
function parseRetry(value: unknown): ComposerStore["retry"] {
if (
!record(value) ||
typeof value.id !== "string" ||
!value.id.startsWith("msg_") ||
typeof value.agent !== "string" ||
typeof value.providerID !== "string" ||
typeof value.modelID !== "string"
) {
return
}
return {
id: SessionMessage.ID.make(value.id),
agent: value.agent,
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
}
}
function parsePart(value: unknown): ContentPart[] {
if (!record(value) || typeof value.type !== "string") return []
if (value.type === "image") {
const legacy = typeof value.dataUrl === "string" ? value.dataUrl : undefined
const blobID = record(value.blob) && typeof value.blob.id === "string" ? value.blob.id : legacy
const hydrated = record(value.blob) && typeof value.blob.url === "string" ? value.blob.url : undefined
const blobURL =
hydrated?.startsWith("blob:") || hydrated?.startsWith("data:")
? hydrated
: blobID?.startsWith("data:")
? blobID
: undefined
if (
typeof value.id !== "string" ||
typeof value.filename !== "string" ||
typeof value.mime !== "string" ||
!blobID ||
!blobURL
) {
return []
}
return [
{
type: "image",
id: value.id,
filename: value.filename,
mime: value.mime,
blob: { id: blobID, url: blobURL },
...(typeof value.sourcePath === "string" ? { sourcePath: value.sourcePath } : {}),
},
]
}
if (typeof value.content !== "string" || typeof value.start !== "number" || typeof value.end !== "number") return []
if (value.type === "text") return [{ type: "text", content: value.content, start: value.start, end: value.end }]
if (value.type === "agent" && typeof value.name === "string") {
return [{ type: "agent", name: value.name, content: value.content, start: value.start, end: value.end }]
}
if (value.type === "skill" && typeof value.id === "string" && typeof value.name === "string") {
return [
{
type: "skill",
id: Skill.ID.make(value.id),
name: Skill.Name.make(value.name),
content: value.content,
start: value.start,
end: value.end,
},
]
}
if (value.type !== "file" || typeof value.path !== "string") return []
const selection = parseSelection(value.selection)
const source = parseSource(value.source)
return [
{
type: "file",
path: value.path,
content: value.content,
start: value.start,
end: value.end,
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
...(typeof value.url === "string" ? { url: value.url } : {}),
...(selection ? { selection } : {}),
...(source ? { source } : {}),
},
]
}
function parseContextItem(value: unknown): ContextItem | undefined {
if (!record(value) || value.type !== "file" || typeof value.path !== "string") return
const selection = parseSelection(value.selection)
const origin = value.commentOrigin === "review" || value.commentOrigin === "file" ? value.commentOrigin : undefined
return {
type: "file",
path: value.path,
...(selection ? { selection } : {}),
...(typeof value.comment === "string" ? { comment: value.comment } : {}),
...(typeof value.commentID === "string" ? { commentID: value.commentID } : {}),
...(origin ? { commentOrigin: origin } : {}),
...(typeof value.preview === "string" ? { preview: value.preview } : {}),
}
}
function parseModel(value: unknown): PromptModel | undefined {
if (!record(value) || typeof value.providerID !== "string" || typeof value.modelID !== "string") return
return {
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" || value.variant === null ? { variant: value.variant } : {}),
}
}
function parseSelection(value: unknown): FileSelection | undefined {
if (!record(value)) return
if (
typeof value.startLine !== "number" ||
typeof value.startChar !== "number" ||
typeof value.endLine !== "number" ||
typeof value.endChar !== "number"
) {
return
}
return {
startLine: value.startLine,
startChar: value.startChar,
endLine: value.endLine,
endChar: value.endChar,
}
}
function parseSource(value: unknown): FilePartSource | undefined {
if (!record(value) || !record(value.text)) return
if (
typeof value.text.value !== "string" ||
typeof value.text.start !== "number" ||
typeof value.text.end !== "number"
) {
return
}
const text = { value: value.text.value, start: value.text.start, end: value.text.end }
if (value.type === "file" && typeof value.path === "string") return { type: "file", path: value.path, text }
if (value.type === "resource" && typeof value.clientName === "string" && typeof value.uri === "string") {
return { type: "resource", clientName: value.clientName, uri: value.uri, text }
}
if (
value.type !== "symbol" ||
typeof value.path !== "string" ||
typeof value.name !== "string" ||
typeof value.kind !== "number" ||
!record(value.range) ||
!record(value.range.start) ||
!record(value.range.end) ||
typeof value.range.start.line !== "number" ||
typeof value.range.start.character !== "number" ||
typeof value.range.end.line !== "number" ||
typeof value.range.end.character !== "number"
) {
return
}
return {
type: "symbol",
path: value.path,
name: value.name,
kind: value.kind,
text,
range: {
start: { line: value.range.start.line, character: value.range.start.character },
end: { line: value.range.end.line, character: value.range.end.character },
},
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
const actions = createComposerActions(setStore)
const clearRetry = () => setStore("retry", undefined)
@@ -442,11 +157,7 @@ function createPersistedComposer(
initial?: InitialPrompt,
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(
target,
createStore<ComposerStore>(initialComposerStore(initial)),
platform,
)
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
return { ready, ...createComposerStateValue(store, setStore) }
}
@@ -460,13 +171,7 @@ export function createComposerState(
}
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
return createPersistedComposer(
{
...Persist.prompt(Persist.draft(draftID, "prompt")),
migrate: parseComposerStore,
},
initial,
)
return createPersistedComposer(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
}
export type ComposerState = ReturnType<typeof createComposerState>
+6 -1
View File
@@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
@@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const standalone = isStandalone()
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
@@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
</AppBaseProviders>
</PlatformProvider>
),
@@ -10,10 +10,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
@@ -22,10 +27,7 @@ export function createHomeProjectsController(home: HomeController) {
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(promise) => promise.then(() => _state),
+6 -1
View File
@@ -2,9 +2,14 @@ import type { HomeProjectsController } from "./controller"
import { HomeProjectsView } from "./view"
import type { HomeScrollController } from "../scroll"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
export function HomeProjects(props: {
projects: HomeProjectsController
scroll: HomeScrollController
dropdown?: boolean
}) {
return (
<HomeProjectsView
dropdown={props.dropdown}
language={props.projects.copy.language}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
+131 -22
View File
@@ -1,5 +1,6 @@
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Popover } from "@kobalte/core/popover"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -28,6 +29,7 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
`project:${ServerConnection.key(server)}:${directory}`
export type HomeProjectsViewProps = {
dropdown?: boolean
language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[]
projects: LocalProject[]
@@ -64,6 +66,81 @@ export type HomeProjectsViewProps = {
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
const [state, setState] = createStore({ open: false })
const selected = createMemo(() => props.projects.find((project) => project.worktree === props.selection.directory))
const server = createMemo(() =>
props.servers.find((server) => ServerConnection.key(server) === props.selection.server),
)
return (
<Show when={props.dropdown} fallback={<HomeProjectsPanel {...props} />}>
<Popover
open={state.open}
onOpenChange={(open) => setState("open", open)}
placement="bottom-start"
sameWidth
gutter={6}
>
<Popover.Trigger
data-component="home-projects-dropdown"
aria-label={props.language.t("home.projects")}
class="flex h-10 w-full min-w-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-base px-1.5 text-start text-v2-text-text-base outline-none hover:bg-v2-background-bg-layer-01"
>
<Show when={selected()} fallback={<Icon name="folder" size="small" />}>
{(project) => <HomeProjectAvatar project={project()} />}
</Show>
<span class="flex min-w-0 flex-1 flex-col leading-[var(--line-height-compact)]">
<bdi class="truncate">
<Show when={selected()} fallback={props.language.t("home.projects.all")}>
{(project) => displayName(project())}
</Show>
</bdi>
<Show when={props.servers.length > 1 && server()}>
{(server) => (
<bdi class="truncate text-v2-text-text-muted opacity-70">
{server().displayName ?? new URL(server().http.url).host}
</bdi>
)}
</Show>
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
aria-label={props.language.t("home.projects")}
dir={props.language.direction()}
class="z-50 max-h-[min(70dvh,var(--kb-popper-content-available-height))] overflow-hidden rounded-[10px] bg-v2-background-bg-base p-1.5 shadow-[var(--v2-elevation-floating)] outline-none data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:slide-in-from-top-2 duration-150 ease-out motion-reduce:animate-none"
>
<HomeProjectsPanel
{...props}
onSelectProject={(server, directory) => {
props.onSelectProject(server, directory)
setState("open", false)
}}
onFocusServer={(server) => {
props.onFocusServer(server)
setState("open", false)
}}
onChooseProject={(server) => {
setState("open", false)
props.onChooseProject(server)
}}
onAddProjects={(server, directories) => {
setState("open", false)
props.onAddProjects(server, directories)
}}
onOpenProjectNewSession={(server, directory) => {
setState("open", false)
props.onOpenProjectNewSession(server, directory)
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
</Show>
)
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -71,48 +148,77 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
return (
<aside
class={`
class={
props.dropdown
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
: `
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
`}
`
}
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
<Show when={!props.dropdown}>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
</Show>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show when={props.dropdown && props.servers.length === 1}>
<HomeProjectNavButton
type="button"
class="mb-1"
data-selected={!props.selection.directory ? "" : undefined}
onClick={() => props.onFocusServer(props.servers[0])}
>
<Icon name="folder" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.projects.all")}</span>
</HomeProjectNavButton>
</Show>
<Show
when={props.servers.length > 1}
fallback={
<div class="pr-3">
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
<For each={props.servers}>
{(item) => {
const projects = () => props.projectsForServer(item)
@@ -157,7 +263,9 @@ export function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<div
class={`${props.class ?? ""} min-w-0 flex-row justify-between gap-1 lg:flex-col lg:justify-start lg:pr-3 [&>button]:w-auto lg:[&>button]:w-full`}
>
<HomeProjectNavButton
type="button"
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
@@ -511,6 +619,7 @@ function HomeProjectRow(
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (props.dropdown) return
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
+23 -11
View File
@@ -1,4 +1,6 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
@@ -9,6 +11,7 @@ import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const mobile = createMediaQuery("(max-width: 767px)")
const home = createHomeController()
const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home)
@@ -17,12 +20,17 @@ export function Home() {
return (
<div
class={`
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
</div>
</Show>
<ScrollView
class="h-full [container-type:size]"
class="min-h-0 flex-1 [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack()}
thumbHoverTarget={scroll.viewport.hoverTarget()}
viewportRef={scroll.viewport.setViewport}
@@ -31,20 +39,24 @@ export function Home() {
>
<div
class={`
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)] gap-4 px-3
max-md:grid-rows-[minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
`}
>
<HomeProjects projects={projects} scroll={scroll} />
<Show when={!mobile()}>
<HomeProjects projects={projects} scroll={scroll} />
</Show>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
<HomeUtilityNav
class="flex lg:hidden"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</ScrollView>
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
<HomeUtilityNav
class="flex"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
@media (max-width: 767px) {
[data-component="home-session-row-container"][data-project-name="true"],
[data-component="home-session-row-container"][data-project-name="true"] [data-component="home-session-row"],
[data-component="home-session-row-container"][data-project-name="true"] [data-slot="home-session-editor"],
[data-component="home-session-search-row"][data-project-name="true"] {
height: 48px;
padding-block: 0;
}
[data-project-name="true"] [data-slot="home-session-labels"] {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0;
text-align: start;
}
[data-project-name="true"] [data-slot="home-session-labels"] > * {
max-width: 100%;
flex: none;
font-size: 13px;
line-height: var(--line-height-compact);
}
}
+76 -54
View File
@@ -15,6 +15,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import "./view.css"
import {
HomeSessionStatusController,
homeSessionSearchKey,
@@ -96,10 +97,13 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.language.t("sidebar.project.recentSessions")}
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
onWheel={props.onWheel}
>
<HomeSessionSearch {...props} />
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
@@ -113,18 +117,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
<div
ref={props.onSetThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<div class="pt-1 md:pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
@@ -138,7 +142,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
@@ -150,7 +154,9 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
/>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
<div
class={`flex min-w-0 flex-col gap-px pt-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
@@ -235,7 +241,11 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
absolute flex flex-col overflow-hidden rounded-[12px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
`}
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
style={{
top: "-6px",
"inset-inline-start": "-6px",
width: "calc(100% + 12px)",
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
@@ -269,7 +279,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
>
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}>
{(record) => (
@@ -291,7 +301,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</Show>
<label
class={`
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
`}
@@ -375,6 +385,7 @@ function HomeSessionSearchResultRow(
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
data-project-name={!!showProjectName()}
role="option"
aria-selected={props.selected}
class={`
@@ -403,7 +414,7 @@ function HomeSessionSearchResultRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} search />
@@ -423,8 +434,8 @@ function HomeSessionGroupHeader(props: {
<div
ref={props.onSetRef}
class={`
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base pl-3 lg:top-[108px]
pointer-events-none sticky top-[68px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base ps-1.5 md:ps-3 md:top-[84px] lg:top-[108px]
`}
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
>
@@ -510,6 +521,7 @@ function HomeSessionRow(
return (
<div
data-component="home-session-row-container"
data-project-name={!!showProjectName()}
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
classList={{ group: !!showProjectName() }}
@@ -523,51 +535,56 @@ function HomeSessionRow(
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<div
data-slot="home-session-editor"
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
<div data-slot="home-session-labels" class="contents">
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</div>
}
>
@@ -578,7 +595,7 @@ function HomeSessionRow(
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
@@ -641,10 +658,12 @@ function HomeSessionRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
<div data-slot="home-session-labels" class="contents">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</button>
</Show>
<Menu
@@ -723,6 +742,7 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
return (
<span
data-component="home-session-title"
dir="auto"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
@@ -738,6 +758,8 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
return (
<span
data-component="home-session-project-name"
dir="auto"
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
>
@@ -779,7 +801,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class="flex h-7 min-w-0 items-center justify-between ps-1.5 pe-4 md:ps-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
+14 -3
View File
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
@@ -20,10 +20,19 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -31,7 +40,8 @@ export function NewSessionView(props: {
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
createStore({ used: false }),
WorkspaceOnboardingSchema,
{ used: false },
)
const select = (value: string) => {
props.workspace.selection.set(value)
@@ -110,7 +120,8 @@ function ProviderTip() {
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const visible = createMemo(
() =>
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+40 -32
View File
@@ -3,10 +3,12 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -15,39 +17,49 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
type State = typeof StateSchema.Type
type Saved = {
session: Record<string, State | undefined>
}
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
}),
),
)
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return
return {
@@ -77,13 +89,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
migrate,
},
createStore<Saved>({
session: {},
}),
Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
ModelSelectionSchema,
{ session: {} },
)
const [store, setStore] = createStore<{
+19 -10
View File
@@ -1,16 +1,25 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
-3
View File
@@ -414,8 +414,6 @@ export const dict = {
"dialog.server.add.button": "አገልጋይ አክል",
"dialog.server.add.name": "የአገልጋይ ስም (አማራጭ)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "የተጠቃሚ ስም (አማራጭ)",
"dialog.server.add.usernamePlaceholder": "የተጠቃሚ ስም",
"dialog.server.add.password": "የይለፍ ቃል (አማራጭ)",
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
"dialog.server.edit.title": "አገልጋይ አርትዕ",
@@ -498,7 +496,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "ዴስክቶፕ {{version}}",
"wsl.onboarding.versionMismatch": "የተጫነው ሥሪት ከዴስክቶፕ መተግበሪያ ሥሪት ጋር አይዛመድም።",
"wsl.onboarding.adding": "በማከል ላይ...",
"server.row.noUsername": "የተጠቃሚ ስም የለም",
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
"dialog.project.edit.name": "ስም",
"dialog.project.edit.icon": "አዶ",
-3
View File
@@ -420,7 +420,6 @@ export const dict = {
"dialog.server.add.button": "إضافة خادم",
"dialog.server.add.name": "اسم الخادم (اختياري)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "اسم المستخدم (اختياري)",
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.default.title": "الخادم الافتراضي",
@@ -1024,9 +1023,7 @@ export const dict = {
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
-3
View File
@@ -421,8 +421,6 @@ export const dict = {
"dialog.server.add.button": "Server əlavə et",
"dialog.server.add.name": "Server adı (istəyə bağlı)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "İstifadəçi adı (istəyə bağlı)",
"dialog.server.add.usernamePlaceholder": "istifadəçi adı",
"dialog.server.add.password": "Şifrə (istəyə bağlı)",
"dialog.server.add.passwordPlaceholder": "parol",
"dialog.server.edit.title": "Serveri redaktə et",
@@ -509,7 +507,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
"wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.",
"wsl.onboarding.adding": "Əlavə edilir...",
"server.row.noUsername": "istifadəçi adı yoxdur",
"dialog.project.edit.title": "Layihəni redaktə et",
"dialog.project.edit.name": "Ad",
"dialog.project.edit.icon": "İkon",
-3
View File
@@ -421,8 +421,6 @@ export const dict = {
"dialog.server.add.button": "Добавете сървър",
"dialog.server.add.name": "Име на сървъра (по избор)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "потребителско име (по избор)",
"dialog.server.add.usernamePlaceholder": "потребителско име",
"dialog.server.add.password": "Парола (по избор)",
"dialog.server.add.passwordPlaceholder": "парола",
"dialog.server.edit.title": "Редактиране на сървъра",
@@ -508,7 +506,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "работен плот {{version}}",
"wsl.onboarding.versionMismatch": "Инсталираната версия не съответства на версията на настолното приложение.",
"wsl.onboarding.adding": "Добавяне...",
"server.row.noUsername": "няма потребителско име",
"dialog.project.edit.title": "Редактиране на проекта",
"dialog.project.edit.name": "Име",
"dialog.project.edit.icon": "Икона",
-3
View File
@@ -418,8 +418,6 @@ export const dict: Record<string, string> = {
"dialog.server.add.button": "সার্ভার যোগ করুন",
"dialog.server.add.name": "সার্ভারের নাম (ঐচ্ছিক)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
"dialog.server.add.usernamePlaceholder": "ব্যবহারকারীর নাম",
"dialog.server.add.password": "পাসওয়ার্ড (ঐচ্ছিক)",
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
@@ -505,7 +503,6 @@ export const dict: Record<string, string> = {
"wsl.onboarding.desktopVersion": "ডেস্কটপ {{version}}",
"wsl.onboarding.versionMismatch": "ইনস্টল করা সংস্করণ ডেস্কটপ অ্যাপ সংস্করণের সাথে মেলে না।",
"wsl.onboarding.adding": "যোগ করা হচ্ছে...",
"server.row.noUsername": "ব্যবহারকারীর নাম নেই",
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
"dialog.project.edit.name": "নাম",
"dialog.project.edit.icon": "আইকন",
-3
View File
@@ -422,7 +422,6 @@ export const dict = {
"dialog.server.add.button": "Adicionar servidor",
"dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)",
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão",
@@ -1035,9 +1034,7 @@ export const dict = {
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
"server.row.noUsername": "sem nome de usuário",
"session.review.noVcs.createGit.title": "Criar um repositório Git",
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
-3
View File
@@ -449,7 +449,6 @@ export const dict = {
"dialog.server.add.button": "Dodaj server",
"dialog.server.add.name": "Ime servera (opcionalno)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Korisničko ime (opcionalno)",
"dialog.server.add.password": "Lozinka (opcionalno)",
"dialog.server.edit.title": "Uredi server",
"dialog.server.default.title": "Podrazumijevani server",
@@ -1113,9 +1112,7 @@ export const dict = {
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
-3
View File
@@ -420,8 +420,6 @@ export const dict = {
"dialog.server.add.button": "Afegeix servidor",
"dialog.server.add.name": "Nom del servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nom d'usuari (opcional)",
"dialog.server.add.usernamePlaceholder": "nom d'usuari",
"dialog.server.add.password": "Contrasenya (opcional)",
"dialog.server.add.passwordPlaceholder": "contrasenya",
"dialog.server.edit.title": "Edita el servidor",
@@ -507,7 +505,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "escriptori {{version}}",
"wsl.onboarding.versionMismatch": "La versió instal·lada no coincideix amb la versió de l'aplicació d'escriptori.",
"wsl.onboarding.adding": "S'està afegint...",
"server.row.noUsername": "sense nom d'usuari",
"dialog.project.edit.title": "Edita el projecte",
"dialog.project.edit.name": "Nom",
"dialog.project.edit.icon": "Icona",
-3
View File
@@ -418,8 +418,6 @@ export const dict = {
"dialog.server.add.button": "Přidat server",
"dialog.server.add.name": "Název serveru (volitelné)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "uživatelské jméno (volitelné)",
"dialog.server.add.usernamePlaceholder": "uživatelské jméno",
"dialog.server.add.password": "Heslo (volitelné)",
"dialog.server.add.passwordPlaceholder": "heslo",
"dialog.server.edit.title": "Upravit server",
@@ -505,7 +503,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "Nainstalovaná verze neodpovídá verzi aplikace pro stolní počítače.",
"wsl.onboarding.adding": "Přidávání...",
"server.row.noUsername": "žádné uživatelské jméno",
"dialog.project.edit.title": "Upravit projekt",
"dialog.project.edit.name": "Jméno",
"dialog.project.edit.icon": "ikona",
-3
View File
@@ -346,7 +346,6 @@ export const dict = {
"dialog.server.add.button": "Tilføj server",
"dialog.server.add.name": "Servernavn (valgfrit)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brugernavn (valgfrit)",
"dialog.server.add.password": "Adgangskode (valgfrit)",
"dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver",
@@ -1003,9 +1002,7 @@ export const dict = {
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
"server.row.noUsername": "intet brugernavn",
"session.review.noVcs.createGit.title": "Opret et Git-repository",
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
-3
View File
@@ -327,7 +327,6 @@ export const dict = {
"dialog.server.add.button": "Server hinzufügen",
"dialog.server.add.name": "Servername (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Benutzername (optional)",
"dialog.server.add.password": "Passwort (optional)",
"dialog.server.edit.title": "Server bearbeiten",
"dialog.server.default.title": "Standardserver",
@@ -945,9 +944,7 @@ export const dict = {
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
"app.server.retrying": "Verbindung wird automatisch erneut hergestellt…",
"app.server.otherServers": "Andere Server",
"dialog.server.add.usernamePlaceholder": "Benutzername",
"dialog.server.add.passwordPlaceholder": "Passwort",
"server.row.noUsername": "Kein Benutzername",
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
"session.review.noVcs.createGit.description":
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
-3
View File
@@ -424,8 +424,6 @@ export const dict = {
"dialog.server.add.button": "ސަރވަރ އިތުރުކުރުން",
"dialog.server.add.name": "ސަރވަރ ނަން (އިޚްތިޔާރީ)",
"dialog.server.add.namePlaceholder": "Localhost އެވެ",
"dialog.server.add.username": "ޔޫޒަރނޭމް (އިޚްތިޔާރީ)",
"dialog.server.add.usernamePlaceholder": "ޔޫޒަރނޭމް",
"dialog.server.add.password": "ޕާސްވޯޑް (އިޚްތިޔާރީ)",
"dialog.server.add.passwordPlaceholder": "ޕާސްވަރޑް",
"dialog.server.edit.title": "އެޑިޓް ސަރވަރ",
@@ -511,7 +509,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "ޑެސްކްޓޮޕް {{version}} އެވެ",
"wsl.onboarding.versionMismatch": "އިންސްޓޯލް ކޮށްފައިވާ ވަރޝަން ޑެސްކްޓޮޕް އެޕް ވަރޝަން އާއި އެއްގޮތެއް ނުވެއެވެ.",
"wsl.onboarding.adding": "އިތުރުކުރަނީ...",
"server.row.noUsername": "ޔޫޒަރނޭމް އެއް ނެތެވެ",
"dialog.project.edit.title": "އެޑިޓް ޕްރޮޖެކްޓް",
"dialog.project.edit.name": "ނަން",
"dialog.project.edit.icon": "އައިކޮން",
-3
View File
@@ -423,8 +423,6 @@ export const dict: Record<string, string> = {
"dialog.server.add.button": "སར་བར་ཁ་སྐོང་འབད།",
"dialog.server.add.name": "སར་བར་གྱི་མིང་ (གདམ་ཁ་ཅན།)",
"dialog.server.add.namePlaceholder": "Localhost།",
"dialog.server.add.username": "ལག་ལེན་པའི་མིང་ (གདམ་ཁ་ཅན་)།",
"dialog.server.add.usernamePlaceholder": "ལག་ལེན་པའི་མིང་།",
"dialog.server.add.password": "ཆོག་ཡིག་ (གདམ་ཁ་ཅན།)",
"dialog.server.add.passwordPlaceholder": "གསང༌ཨང",
"dialog.server.edit.title": "སར་བར་ཞུན་དག་འབད།",
@@ -510,7 +508,6 @@ export const dict: Record<string, string> = {
"wsl.onboarding.desktopVersion": "ཌེཀསི་ཊོཔ་ {{version}}",
"wsl.onboarding.versionMismatch": "གཞི་བཙུགས་འབད་ཡོད་པའི་ཐོན་རིམ་འདི་ ཌེཀསི་ཊོཔ་ཨེཔ་ཐོན་རིམ་དང་མཐུན་སྒྲིག་མི་འབད།",
"wsl.onboarding.adding": "ཁ་སྐོང་བརྐྱབ་དོ།",
"server.row.noUsername": "སྤྱོད་མིང་མེད།",
"dialog.project.edit.title": "ཞུན་དག་ལས་གཞི།",
"dialog.project.edit.name": "མིང",
"dialog.project.edit.icon": "ངོས་དཔར།",
-3
View File
@@ -420,8 +420,6 @@ export const dict = {
"dialog.server.add.button": "Προσθήκη διακομιστή",
"dialog.server.add.name": "Όνομα διακομιστή (προαιρετικό)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Όνομα χρήστη (προαιρετικό)",
"dialog.server.add.usernamePlaceholder": "όνομα χρήστη",
"dialog.server.add.password": "Κωδικός πρόσβασης (προαιρετικό)",
"dialog.server.add.passwordPlaceholder": "κωδικός πρόσβασης",
"dialog.server.edit.title": "Επεξεργασία διακομιστή",
@@ -507,7 +505,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "επιτραπέζιος υπολογιστής {{version}}",
"wsl.onboarding.versionMismatch": "Η εγκατεστημένη έκδοση δεν ταιριάζει με την έκδοση της εφαρμογής για υπολογιστές.",
"wsl.onboarding.adding": "Προσθήκη...",
"server.row.noUsername": "χωρίς όνομα χρήστη",
"dialog.project.edit.title": "Επεξεργασία έργου",
"dialog.project.edit.name": "Όνομα",
"dialog.project.edit.icon": "Εικονίδιο",
+11 -5
View File
@@ -363,9 +363,7 @@ export const dict = {
"dialog.server.add.button": "Add server",
"dialog.server.add.name": "Server name (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Username (optional)",
"dialog.server.add.usernamePlaceholder": "username",
"dialog.server.add.password": "Password (optional)",
"dialog.server.add.password": "Password",
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
@@ -453,7 +451,6 @@ export const dict = {
"wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
"wsl.onboarding.adding": "Adding...",
"server.row.noUsername": "no username",
"server.row.incompatible":
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
@@ -646,6 +643,7 @@ export const dict = {
"home.empty.description": "Get started by opening a local project",
"home.title": "Home",
"home.projects": "Projects",
"home.projects.all": "All projects",
"home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects",
@@ -662,6 +660,10 @@ export const dict = {
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"session.tab.session": "Session",
"session.tab.files": "Files",
"session.files.openTabs": "Open files",
"session.tab.usage": "Usage",
"session.view.select": "Session view",
"session.tab.review": "Review",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
@@ -700,6 +702,7 @@ export const dict = {
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.wrapLines": "Wrap lines",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noVcs.createGit.title": "Create a Git repository",
@@ -802,6 +805,7 @@ export const dict = {
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -979,7 +983,9 @@ export const dict = {
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"Place the title bar at the bottom of the screen on mobile",
"settings.general.row.mobileDiffWrap.description":
"Wrap long lines in mobile diffs instead of scrolling horizontally",
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
-3
View File
@@ -450,7 +450,6 @@ export const dict = {
"dialog.server.add.button": "Añadir servidor",
"dialog.server.add.name": "Nombre del servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nombre de usuario (opcional)",
"dialog.server.add.password": "Contraseña (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor predeterminado",
@@ -1118,9 +1117,7 @@ export const dict = {
"app.server.unreachable": "No se pudo conectar con {{server}}",
"app.server.retrying": "Reintentando automáticamente...",
"app.server.otherServers": "Otros servidores",
"dialog.server.add.usernamePlaceholder": "usuario",
"dialog.server.add.passwordPlaceholder": "contraseña",
"server.row.noUsername": "sin usuario",
"session.review.noVcs.createGit.title": "Crear repositorio Git",
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
-3
View File
@@ -417,8 +417,6 @@ export const dict = {
"dialog.server.add.button": "Lisa server",
"dialog.server.add.name": "Serveri nimi (valikuline)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Kasutajanimi (valikuline)",
"dialog.server.add.usernamePlaceholder": "kasutajanimi",
"dialog.server.add.password": "Parool (valikuline)",
"dialog.server.add.passwordPlaceholder": "parool",
"dialog.server.edit.title": "Muuda serverit",
@@ -503,7 +501,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "töölaud {{version}}",
"wsl.onboarding.versionMismatch": "Installitud versioon ei ühti töölauarakenduse versiooniga.",
"wsl.onboarding.adding": "Lisamine...",
"server.row.noUsername": "kasutajanime pole",
"dialog.project.edit.title": "Redigeeri projekti",
"dialog.project.edit.name": "Nimi",
"dialog.project.edit.icon": "Ikoon",
-3
View File
@@ -418,8 +418,6 @@ export const dict = {
"dialog.server.add.button": "سرور اضافه کنید",
"dialog.server.add.name": "نام سرور (اختیاری)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "نام کاربری (اختیاری)",
"dialog.server.add.usernamePlaceholder": "نام کاربری",
"dialog.server.add.password": "رمز عبور (اختیاری)",
"dialog.server.add.passwordPlaceholder": "رمز عبور",
"dialog.server.edit.title": "ویرایش سرور",
@@ -505,7 +503,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "دسکتاپ {{version}}",
"wsl.onboarding.versionMismatch": "نسخه نصب شده با نسخه برنامه دسکتاپ مطابقت ندارد.",
"wsl.onboarding.adding": "در حال افزودن...",
"server.row.noUsername": "بدون نام کاربری",
"dialog.project.edit.title": "ویرایش پروژه",
"dialog.project.edit.name": "نام",
"dialog.project.edit.icon": "نماد",
-3
View File
@@ -325,8 +325,6 @@ export const dict = {
"dialog.server.add.button": "Lisää palvelin",
"dialog.server.add.name": "Palvelimen nimi (valinnainen)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Käyttäjätunnus (valinnainen)",
"dialog.server.add.usernamePlaceholder": "käyttäjätunnus",
"dialog.server.add.password": "Salasana (valinnainen)",
"dialog.server.add.passwordPlaceholder": "salasana",
"dialog.server.edit.title": "Muokkaa palvelinta",
@@ -412,7 +410,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "Desktop {{version}}",
"wsl.onboarding.versionMismatch": "Asennettu versio ei vastaa työpöytäsovelluksen versiota.",
"wsl.onboarding.adding": "Lisätään...",
"server.row.noUsername": "ei käyttäjätunnusta",
"dialog.project.edit.title": "Muokkaa projektia",
"dialog.project.edit.name": "Nimi",
"dialog.project.edit.icon": "Kuvake",
-3
View File
@@ -417,8 +417,6 @@ export const dict = {
"dialog.server.add.button": "Legg ambætara til",
"dialog.server.add.name": "Ambætaranavn (valfrítt)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brúkaranavn (valfrítt)",
"dialog.server.add.usernamePlaceholder": "brúkaranavn",
"dialog.server.add.password": "Loyniorð (valfrítt)",
"dialog.server.add.passwordPlaceholder": "loyniorð",
"dialog.server.edit.title": "Rætta ambætara",
@@ -504,7 +502,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "skriviborð {{version}}",
"wsl.onboarding.versionMismatch": "Innsetta útgávan passar ikki til skriviborðsappiútgávuna.",
"wsl.onboarding.adding": "Leggi til...",
"server.row.noUsername": "einki brúkaranavn",
"dialog.project.edit.title": "Rætta verkætlan",
"dialog.project.edit.name": "Navn",
"dialog.project.edit.icon": "Ikon",
-3
View File
@@ -426,7 +426,6 @@ export const dict = {
"dialog.server.add.button": "Ajouter un serveur",
"dialog.server.add.name": "Nom du serveur (optionnel)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nom d'utilisateur (optionnel)",
"dialog.server.add.password": "Mot de passe (optionnel)",
"dialog.server.edit.title": "Modifier le serveur",
"dialog.server.default.title": "Serveur par défaut",
@@ -1044,9 +1043,7 @@ export const dict = {
"app.server.unreachable": "Impossible de joindre {{server}}",
"app.server.retrying": "Nouvelle tentative automatique...",
"app.server.otherServers": "Autres serveurs",
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
"dialog.server.add.passwordPlaceholder": "mot de passe",
"server.row.noUsername": "aucun nom d'utilisateur",
"session.review.noVcs.createGit.title": "Créer un dépôt Git",
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
-3
View File
@@ -416,8 +416,6 @@ export const dict = {
"dialog.server.add.button": "הוסף שרת",
"dialog.server.add.name": "שם שרת (אופציונלי)",
"dialog.server.add.namePlaceholder": "מארח מקומי",
"dialog.server.add.username": "שם משתמש (אופציונלי)",
"dialog.server.add.usernamePlaceholder": "שם משתמש",
"dialog.server.add.password": "סיסמה (אופציונלי)",
"dialog.server.add.passwordPlaceholder": "סיסמה",
"dialog.server.edit.title": "ערוך שרת",
@@ -501,7 +499,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "גרסת שולחן העבודה: {{version}}",
"wsl.onboarding.versionMismatch": "הגרסה המותקנת אינה תואמת את גרסת האפליקציה לשולחן העבודה.",
"wsl.onboarding.adding": "מוסיף...",
"server.row.noUsername": "ללא שם משתמש",
"dialog.project.edit.title": "ערוך פרויקט",
"dialog.project.edit.name": "שם",
"dialog.project.edit.icon": "סמל",
-3
View File
@@ -425,8 +425,6 @@ export const dict = {
"dialog.server.add.button": "सर्वर जोड़ें",
"dialog.server.add.name": "सर्वर नाम (वैकल्पिक)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "उपयोगकर्ता नाम (वैकल्पिक)",
"dialog.server.add.usernamePlaceholder": "उपयोक्तानाम",
"dialog.server.add.password": "पासवर्ड (वैकल्पिक)",
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
"dialog.server.edit.title": "सर्वर संपादित करें",
@@ -512,7 +510,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "डेस्कटॉप {{version}}",
"wsl.onboarding.versionMismatch": "इंस्टॉल किया गया संस्करण डेस्कटॉप ऐप संस्करण से मेल नहीं खाता।",
"wsl.onboarding.adding": "जोड़ा जा रहा है...",
"server.row.noUsername": "कोई उपयोगकर्ता नाम नहीं",
"dialog.project.edit.title": "प्रोजेक्ट संपादित करें",
"dialog.project.edit.name": "नाम",
"dialog.project.edit.icon": "आइकन",
-3
View File
@@ -422,8 +422,6 @@ export const dict = {
"dialog.server.add.button": "Dodaj poslužitelj",
"dialog.server.add.name": "Naziv poslužitelja (neobavezno)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "korisničko ime (nije obavezno)",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.password": "Lozinka (nije obavezno)",
"dialog.server.add.passwordPlaceholder": "lozinka",
"dialog.server.edit.title": "Uredi poslužitelj",
@@ -509,7 +507,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "radna površina {{version}}",
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji aplikacije za stolna računala.",
"wsl.onboarding.adding": "Dodavanje...",
"server.row.noUsername": "nema korisničkog imena",
"dialog.project.edit.title": "Uredi projekt",
"dialog.project.edit.name": "Ime",
"dialog.project.edit.icon": "Ikona",
-3
View File
@@ -422,8 +422,6 @@ export const dict = {
"dialog.server.add.button": "Szerver hozzáadása",
"dialog.server.add.name": "Szerver neve (nem kötelező)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Felhasználónév (nem kötelező)",
"dialog.server.add.usernamePlaceholder": "felhasználónév",
"dialog.server.add.password": "Jelszó (nem kötelező)",
"dialog.server.add.passwordPlaceholder": "jelszó",
"dialog.server.edit.title": "Szerver szerkesztése",
@@ -509,7 +507,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "asztali {{version}}",
"wsl.onboarding.versionMismatch": "A telepített verzió nem egyezik az asztali alkalmazás verziójával.",
"wsl.onboarding.adding": "Hozzáadás...",
"server.row.noUsername": "nincs felhasználónév",
"dialog.project.edit.title": "Projekt szerkesztése",
"dialog.project.edit.name": "Név",
"dialog.project.edit.icon": "Ikon",
-3
View File
@@ -420,8 +420,6 @@ export const dict = {
"dialog.server.add.button": "Ավելացնել սերվեր",
"dialog.server.add.name": "Սերվերի անունը (ըստ ցանկության)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Օգտագործողի անուն (ըստ ցանկության)",
"dialog.server.add.usernamePlaceholder": "օգտվողի անուն",
"dialog.server.add.password": "Գաղտնաբառ (ըստ ցանկության)",
"dialog.server.add.passwordPlaceholder": "գաղտնաբառ",
"dialog.server.edit.title": "Խմբագրել սերվերը",
@@ -507,7 +505,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "աշխատասեղանի {{version}}",
"wsl.onboarding.versionMismatch": "Տեղադրված տարբերակը չի համապատասխանում աշխատասեղանի հավելվածի տարբերակին։",
"wsl.onboarding.adding": "Ավելացվում է...",
"server.row.noUsername": "առանց օգտվողի անուն",
"dialog.project.edit.title": "Խմբագրել նախագիծը",
"dialog.project.edit.name": "Անուն",
"dialog.project.edit.icon": "Պատկերակ",
-3
View File
@@ -453,8 +453,6 @@ export const dict = {
"dialog.server.add.button": "Tambah server",
"dialog.server.add.name": "Nama server (opsional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nama pengguna (opsional)",
"dialog.server.add.usernamePlaceholder": "nama pengguna",
"dialog.server.add.password": "Kata sandi (opsional)",
"dialog.server.add.passwordPlaceholder": "kata sandi",
"dialog.server.edit.title": "Sunting server",
@@ -542,7 +540,6 @@ export const dict = {
"wsl.onboarding.versionMismatch": "Versi terinstal tidak cocok dengan versi aplikasi desktop.",
"wsl.onboarding.adding": "Menambahkan...",
"server.row.noUsername": "tanpa nama pengguna",
"dialog.project.edit.title": "Sunting proyek",
"dialog.project.edit.name": "Nama",
-3
View File
@@ -422,8 +422,6 @@ export const dict = {
"dialog.server.add.button": "Bæta við netþjóni",
"dialog.server.add.name": "Nafn netþjóns (valfrjálst)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Notandanafn (valfrjálst)",
"dialog.server.add.usernamePlaceholder": "notendanafn",
"dialog.server.add.password": "Lykilorð (valfrjálst)",
"dialog.server.add.passwordPlaceholder": "lykilorð",
"dialog.server.edit.title": "Breyta miðlara",
@@ -509,7 +507,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "skrifborð {{version}}",
"wsl.onboarding.versionMismatch": "Uppsett útgáfa passar ekki við útgáfu skrifborðsforritsins.",
"wsl.onboarding.adding": "Bætir við...",
"server.row.noUsername": "ekkert notendanafn",
"dialog.project.edit.title": "Breyta verkefni",
"dialog.project.edit.name": "Nafn",
"dialog.project.edit.icon": "Táknmynd",
-3
View File
@@ -327,8 +327,6 @@ export const dict = {
"dialog.server.add.button": "Aggiungi server",
"dialog.server.add.name": "Nome del server (facoltativo)",
"dialog.server.add.namePlaceholder": "Host locale",
"dialog.server.add.username": "Nome utente (facoltativo)",
"dialog.server.add.usernamePlaceholder": "nomeutente",
"dialog.server.add.password": "Password (facoltativa)",
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Modifica server",
@@ -414,7 +412,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "La versione installata non corrisponde alla versione dell'app desktop.",
"wsl.onboarding.adding": "Aggiunta...",
"server.row.noUsername": "nessun nome utente",
"dialog.project.edit.title": "Modifica progetto",
"dialog.project.edit.name": "Nome",
"dialog.project.edit.icon": "Icona",
-3
View File
@@ -419,7 +419,6 @@ export const dict = {
"dialog.server.add.button": "サーバーを追加",
"dialog.server.add.name": "サーバー名 (オプション)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "ユーザー名 (オプション)",
"dialog.server.add.password": "パスワード (オプション)",
"dialog.server.edit.title": "サーバーを編集",
"dialog.server.default.title": "デフォルトサーバー",
@@ -1019,9 +1018,7 @@ export const dict = {
"app.server.unreachable": "{{server}} に到達できませんでした",
"app.server.retrying": "自動的に再試行中...",
"app.server.otherServers": "その他のサーバー",
"dialog.server.add.usernamePlaceholder": "ユーザー名",
"dialog.server.add.passwordPlaceholder": "パスワード",
"server.row.noUsername": "ユーザー名なし",
"session.review.noVcs.createGit.title": "Git リポジトリを作成",
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
-3
View File
@@ -418,8 +418,6 @@ export const dict = {
"dialog.server.add.button": "სერვერის დამატება",
"dialog.server.add.name": "სერვერის სახელი (არასავალდებულო)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "მომხმარებლის სახელი (არასავალდებულო)",
"dialog.server.add.usernamePlaceholder": "მომხმარებლის სახელი",
"dialog.server.add.password": "პაროლი (არასავალდებულო)",
"dialog.server.add.passwordPlaceholder": "პაროლი",
"dialog.server.edit.title": "სერვერის რედაქტირება",
@@ -505,7 +503,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "დესკტოპის {{version}}",
"wsl.onboarding.versionMismatch": "დაინსტალირებული ვერსია არ ემთხვევა დესკტოპის აპის ვერსიას.",
"wsl.onboarding.adding": "დამატება...",
"server.row.noUsername": "მომხმარებლის სახელის გარეშე",
"dialog.project.edit.title": "პროექტის რედაქტირება",
"dialog.project.edit.name": "სახელი",
"dialog.project.edit.icon": "ხატულა",

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