Compare commits

...

57 Commits

Author SHA1 Message Date
Kit Langton 8f903f899a refactor(core): make form ledger global
Pending forms are transient runtime state keyed by Session, so the Form
service now lives in the global node graph instead of each Location
instance. Reading or settling a Session's forms no longer boots that
Session's Location; the TUI's per-descendant form sync had been
reactivating every idle Location on startup.

Events keep routing per Location: the ledger resolves the owning
Session's Location from SessionStore once at creation (or the ambient
Location for the MCP global elicitation owner) and publishes with it
explicitly. form.request.list keeps its per-Location shape by filtering
the global ledger on that route.
2026-09-01 21:18:49 -04:00
Kit Langton 7f2645a8f4 test(core): make Windows-flaky shell and npm tests deterministic 2026-09-01 20:45:14 -04:00
Brendan Allan 5ee7f19875 refactor(app): drive persisted state with Effect Schema (#46558) 2026-09-02 08:44:22 +08:00
Dax e76e90b71e refactor(core): decouple plugins from config loading (#46639) 2026-09-01 20:37:42 -04:00
Filip c806503694 fix generated docs (#46678) 2026-09-01 22:37:37 +02:00
Filip fd73a85de4 remove azure discovery (#46672) 2026-09-01 22:13:08 +02:00
opencode-agent[bot] e2e82f18e2 docs: clarify branch targeting guidance (#46645)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-09-01 13:21:13 -05:00
opencode-agent[bot] 91cdb182f0 test(tui): remove flaky jump-to-latest test (#46640)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 13:54:56 -04:00
James Long 316f7925d3 refactor(client): centralize service handoff in stop (#46637) 2026-09-01 13:49:45 -04:00
Shoubhit Dash 9f01e2b548 fix(plugin): make load failures easier to diagnose (#46594) 2026-09-01 22:29:35 +05:30
Shoubhit Dash 7856515140 feat(tui): add shareable stats poster (#46563) 2026-09-01 22:21:52 +05:30
Dax Raad 818804e181 chore: refresh bun lockfile 2026-09-01 12:19:40 -04:00
Aiden Cline ce6247bd2f fix(core): estimate context growth before compaction (#46543) 2026-09-01 10:48:34 -05:00
opencode-agent[bot] 0465328297 test(tui): use real session id in home fixture (#46618)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-01 10:39:04 -05:00
Dax Raad 43d09b9d75 fix(server): await plugin activation when checking updates 2026-09-01 11:16:02 -04:00
Dax Raad 7730cec123 fix(tui): limit cached non-tab session families 2026-09-01 11:14:01 -04:00
opencode-agent[bot] 367ee47d7e fix(tui): tone down patch failure details (#46470)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-01 11:07:03 -04:00
David Hill c596a8f11d fix(tui): simplify narrow interrupt footer (#46533)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 11:06:25 -04:00
Kit Langton 30f998c5b9 feat(tui): make session preview tabs the default (#46497) 2026-09-01 11:02:11 -04:00
Kit Langton d4b4dd17cc refactor(core): rename plugin flush to awaitActivation 2026-09-01 10:48:08 -04:00
Dax Raad f330f3e02b feat(cli): check and update plugins with flat inventory 2026-09-01 10:46:47 -04:00
opencode-agent[bot] a9c27209bb fix(tui): open rename dialog from tab menu (#46603)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-01 19:58:36 +05:30
Kit Langton 831f8f6cd1 fix(ai): reconcile final response calls by call id (#46084) 2026-09-01 09:47:10 -04:00
Victor Navarro cff1d0fe01 fix(core): preserve legacy Console reasoning variants (#46586) 2026-09-01 13:45:26 +02:00
Simon Klee e297da82ae fix(tui): mini defer prompt echo until delivery (#46578) 2026-09-01 13:29:18 +02:00
Victor Navarro 77eac47493 feat(core): support canonical provider config (#46134) 2026-09-01 12:19:02 +02:00
Victor Navarro aadc0c1b4b fix: stabilize cross-platform unit tests (#46569)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-01 11:38:55 +02:00
Victor Navarro 94caa36fd4 test(server): wait for plugin readiness (#46567) 2026-09-01 10:52:45 +02:00
Brendan Allan a6f75d483a fix(app): follow file tree order in review navigation (#46557) 2026-09-01 07:25:08 +00:00
Aiden Cline 000b42d204 refactor(core): nest code mode catalog (#46541) 2026-09-01 00:01:31 -05:00
Aiden Cline 02440f6715 feat(codemode): label item and value schema comments (#46542) 2026-08-31 23:54:58 -05:00
Brendan Allan d46ed9e9db Session message style (#46538)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-09-01 04:49:16 +00:00
opencode-agent[bot] 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] 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
opencode-agent[bot] 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] 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] 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] 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] 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
394 changed files with 11789 additions and 14641 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
+3 -13
View File
@@ -969,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": {
@@ -978,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:",
},
},
@@ -3211,8 +3213,6 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -5451,8 +5451,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5667,8 +5665,6 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5817,9 +5813,7 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5851,8 +5845,6 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -6607,8 +6599,6 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"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": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
+24 -7
View File
@@ -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>>
}
@@ -952,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,
@@ -970,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,
@@ -1086,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 : []
@@ -1099,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
@@ -1244,9 +1257,12 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1419,6 +1435,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -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) => {
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -1,5 +1,16 @@
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}`)
@@ -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}` },
)
}
@@ -107,8 +107,6 @@ for (const position of ["top", "bottom"] as const) {
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()
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.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-status-overlay"]')
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
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-status-drag-handle"]')
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)
@@ -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}`)
@@ -1,103 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -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 })
@@ -68,41 +68,20 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
refresh.resolve()
})
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
@@ -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
}
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(
await expectToken(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
@@ -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);
}
+1 -3
View File
@@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -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>
@@ -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),
+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(
() =>
@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+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,
+22 -11
View File
@@ -1,6 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
I18nProvider,
@@ -11,6 +12,7 @@ import {
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode-ai/ui/i18n/en"
import {
@@ -54,6 +56,16 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
const base = flatten({ ...en, ...dict })
@@ -148,17 +160,21 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
}
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
} catch {
return
}
@@ -182,14 +198,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
gate: false,
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language"),
createStore({
locale: initial,
}),
)
const [store, setStore, _, ready] = persisted(Persist.global("language"), languageSchema, { locale: initial })
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const locale = createMemo(() => store.locale)
const intl = createMemo(() => INTL[locale()])
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
@@ -0,0 +1,80 @@
# Persisted State
`persisted(target, schema, initial, platformOverride?)` creates a Solid store whose
type comes from an Effect Schema codec. The required `initial` value supplies
store defaults and is checked against that type. The function returns the store, setter, storage
initialization result, and readiness accessor. Both web and desktop use this
boundary, including cross-window updates.
```ts
const Preferences = Persistence.struct({
visible: Schema.Boolean,
mode: Schema.Literals(["normal", "shell"]),
directory: Persistence.optional(Schema.String),
recent: Persistence.array(Schema.String),
})
type Preferences = typeof Preferences.Type
const [preferences, setPreferences, , ready] = persisted(Persist.global("preferences"), Preferences, {
visible: true,
mode: "normal",
recent: [],
})
```
Keep initialization defaults in `initial`, not repeated across schema fields.
Plain struct fields recover independently from the corresponding initial value;
the resulting state is validated before entering the store. Arrays replace rather
than index-merge, explicit `null` is retained when allowed, and missing optional
values can inherit dynamic initial defaults.
Field codecs decode atomically, so the persistence layer does not attempt to
interpret arbitrary transformations. Collection-entry recovery and genuine
migration rules remain explicit in their schemas.
- `Persistence.fallback(schema, factory)` deliberately recovers invalid values as
well as missing or undefined input. Use it for domain-specific recovery, such as
defaults inside collection entries, not ordinary store initialization.
- `Persistence.optional(schema)` omits invalid fields as well as accepting missing
or undefined input. Use ordinary `Schema.optional` when no codec-local recovery
is needed; the initialized store boundary still recovers fields from `initial`.
- `Persistence.struct(fields)` makes fields mutable for Solid stores while preserving
each field's optionality and codec. It does not add defaults or error recovery.
- `Persistence.record(valueSchema)` creates a mutable string-keyed record, defaulting
missing or invalid records to a fresh `{}`. Its value schema determines entry
recovery: pass `Persistence.optional(valueSchema)` to discard only invalid entries,
or `Persistence.fallback(valueSchema, factory)` to replace those entries.
- `Persistence.array(schema)` defaults to an empty mutable array and discards
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Migrations
Describe shipped representations with schemas and transform their typed values
using `Schema.decode` or `Schema.decodeTo` and `SchemaGetter`. For whole-object
migrations, pass `Persistence.migrate(currentSchema, storedCodec)` instead of the
plain schema. The stored codec runs before defaults are applied, preserving
distinctions such as an absent current field identifying an older format. It
returns a candidate in the current encoded shape; the current schema then owns
recovery and validation.
The migration reader preserves excess properties so a migration can describe
only the fields it observes without dropping unrelated saved preferences. The
current schema strips fields outside its contract. Writes use only the current
schema's encoder, never the legacy reader's encoder.
`Persistence.withInitial(schemaOrMigration, initial)` exposes the same initialized
codec for focused tests. Test canonical encoding and decode/encode/decode stability
as well as historical fixtures.
Reads normalize stored JSON through decoding and encoding, writing back the
canonical representation when it changed. Invalid documents fall back to initial
state; malformed individual values can instead be recovered by their schemas.
Cross-window values are decoded before entering the store. Writes use the same
codec's encoder.
Storage-key relocation (`previousKey`, workspace aliases, draft storage moves)
remains separate from schema migration. Draft blob externalization and hydration
also remain in the storage adapter: composer codecs receive hydrated references,
not raw ID-only blob documents.
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
expect(tip({})).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
collapsed: { open: false, closed: true, invalid: false },
})
}
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {},
pick: { session1: { agent: "plan" } },
}),
).toEqual({ session: {} })
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
invalid: "build",
},
})
expect(state.session.good).toEqual({
agent: "build",
model: { providerID: "provider", modelID: "model", variant: "high" },
variant: null,
})
expect(state.session.partial?.agent).toBe("plan")
expect(state.session.partial?.model).toBeUndefined()
expect(state.session.partial?.variant).toBeUndefined()
expect(state.session.invalid).toBeUndefined()
})
test("file views validate scroll positions and line sides independently", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
good: {
scrollTop: 12,
scrollLeft: 4,
selectedLines: { start: 9, end: 2, side: "deletions", endSide: "additions" },
},
partial: { scrollTop: "12", scrollLeft: 8, selectedLines: { start: 1, end: 3, side: "invalid" } },
cleared: { selectedLines: null },
invalid: false,
},
})
expect(state.file.good).toEqual({
scrollTop: 12,
scrollLeft: 4,
selectedLines: { start: 9, end: 2, side: "deletions", endSide: "additions" },
})
expect(state.file.partial?.scrollTop).toBeUndefined()
expect(state.file.partial?.scrollLeft).toBe(8)
expect(state.file.partial?.selectedLines).toEqual({ start: 1, end: 3 })
expect(state.file.cleared?.selectedLines).toBeNull()
expect(state.file.invalid).toEqual({})
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
expect(decode({ locale: "unsupported" })).toEqual({ locale: "en" })
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,4 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Option, Schema } from "effect"
export type BlobReference = { id: string; url: string }
@@ -81,7 +82,11 @@ export function createDraftStore(driver: Driver): DraftStore {
return {
getItem: async (key) => {
const value = await driver.get(key)
return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
if (value === null) return null
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
return JSON.stringify(await decode(parsed.value))
},
setItem: async (key, value) => {
const version = (versions.get(key) ?? 0) + 1
@@ -0,0 +1,162 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { Persistence } from "./schema"
describe("persistence schemas", () => {
test("initial state supplies nested defaults without hiding valid siblings", () => {
const schema = Persistence.withInitial(
Persistence.struct({
enabled: Schema.Boolean,
appearance: Persistence.struct({ width: Schema.Number, font: Schema.String }),
variant: Schema.optional(Schema.NullOr(Schema.String)),
}),
{ enabled: true, appearance: { width: 240, font: "default" }, variant: "high" },
)
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ appearance: { width: 300 } })).toEqual({
enabled: true,
appearance: { width: 300, font: "default" },
variant: "high",
})
expect(decode({ enabled: "false", appearance: { width: "wide", font: "saved" }, variant: null })).toEqual({
enabled: true,
appearance: { width: 240, font: "saved" },
variant: null,
})
expect(decode({ appearance: null })).toEqual({
enabled: true,
appearance: { width: 240, font: "default" },
variant: "high",
})
})
test("legacy migration observes missing fields before initial defaults are applied", () => {
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
const stored = Schema.Struct({
mode: Schema.optional(Schema.Unknown),
expanded: Schema.optional(Schema.Boolean),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) =>
value.mode !== undefined || value.expanded === undefined
? value
: { ...value, mode: value.expanded ? "full" : "compact" },
),
encode: SchemaGetter.passthrough(),
}),
)
const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false })
expect(decode({ expanded: true, mode: "compact" })).toEqual({ mode: "compact", enabled: true })
expect(decode({ expanded: true, mode: "invalid" })).toEqual({ mode: "compact", enabled: true })
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
})
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
const current = Persistence.struct({
amount: Schema.NumberFromString.check(Schema.isFinite()),
items: Schema.mutable(Schema.Array(Schema.String)),
})
const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] })
expect(decode({ amount: "invalid", items: ["saved"] })).toEqual({ amount: 7, items: ["saved"] })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ amount: "7", items: ["initial"] })
})
test("built-in defaults only recover absent values, not invalid ones", () => {
const schema = Persistence.struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true })
expect(decode({ enabled: undefined })).toEqual({ enabled: true })
expect(() => decode({ enabled: "true" })).toThrow()
const state: typeof schema.Type = { enabled: false }
state.enabled = true
expect(Schema.encodeSync(schema)(state)).toEqual({ enabled: true })
})
test("defaults missing and invalid fields without discarding valid siblings", () => {
const schema = Schema.Struct({
enabled: Persistence.fallback(Schema.Boolean, () => true),
label: Persistence.fallback(Schema.String, () => "default"),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true, label: "default" })
expect(decode({ enabled: "false", label: "saved" })).toEqual({ enabled: true, label: "saved" })
expect(decode({ enabled: undefined, label: null })).toEqual({ enabled: true, label: "default" })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ enabled: true, label: "default" })
})
test("optional recovery keeps fields optional without adding an undefined default", () => {
const number = Schema.NumberFromString.check(Schema.isFinite())
const schema = Persistence.struct({ value: Persistence.optional(number) })
const decode = Schema.decodeUnknownSync(schema)
const empty: typeof schema.Type = {}
expect(decode({})).toEqual(empty)
expect(decode({ value: "invalid" })).toEqual(empty)
expect(Object.hasOwn(decode({ value: "invalid" }), "value")).toBe(false)
expect(decode({ value: undefined })).toEqual({ value: undefined })
expect(decode({ value: "42" })).toEqual({ value: 42 })
expect(Schema.encodeSync(schema)({ value: 42 })).toEqual({ value: "42" })
expect(Schema.encodeSync(schema)(empty)).toEqual({})
expect(() =>
Schema.decodeUnknownSync(Schema.Struct({ value: Schema.optional(number) }))({ value: "invalid" }),
).toThrow()
})
test("fallbacks use decoded values and retain the codec on writes", () => {
const schema = Persistence.struct({
value: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ value: 7 })
expect(decode({ value: "invalid" })).toEqual({ value: 7 })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ value: "7" })
})
test("records default to fresh mutable objects and keep entry recovery explicit", () => {
const strict = Persistence.record(Schema.Boolean)
const decode = Schema.decodeUnknownSync(strict)
expect(decode(undefined)).toEqual({})
expect(decode([])).toEqual({})
expect(decode({ valid: true, invalid: "true" })).toEqual({})
const first: typeof strict.Type = decode(undefined)
first.changed = true
expect(decode(undefined)).toEqual({})
const recover = Persistence.record(Persistence.optional(Schema.Boolean))
const state = Schema.decodeUnknownSync(recover)({ valid: true, invalid: "true" })
expect(state).toEqual({ valid: true })
expect(Schema.encodeSync(recover)(state)).toEqual({ valid: true })
})
test("creates fresh default collections", () => {
const schema = Schema.Struct({ items: Persistence.array(Schema.String) })
const decode = Schema.decodeUnknownSync(schema)
const first = decode({})
first.items.push("changed")
expect(decode({}).items).toEqual([])
})
test("recovers and migrates individual array entries", () => {
const current = Schema.Struct({ name: Schema.String })
const schema = Persistence.array(
Schema.Union([current, Schema.String]).pipe(
Schema.decodeTo(current, {
decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)),
encode: SchemaGetter.passthrough(),
}),
),
)
const decode = Schema.decodeUnknownSync(schema)
const value = decode(["old", { name: "new" }, null, { name: false }])
expect(value).toEqual([{ name: "old" }, { name: "new" }])
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode(undefined)).toEqual([])
expect(decode({})).toEqual([])
})
})
@@ -0,0 +1,99 @@
export * as Persistence from "./schema"
import { Effect, Option, Predicate, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, Struct } from "effect"
export type Migrated<S extends Schema.ConstraintCodec<object, unknown>> = {
current: S
read: Schema.ConstraintDecoder<unknown>
}
export function migrate<S extends Schema.ConstraintCodec<object, unknown>>(
current: S,
read: Schema.ConstraintDecoder<unknown>,
): Migrated<S> {
return { current, read }
}
function isMigrated<S extends Schema.ConstraintCodec<object, unknown>>(schema: S | Migrated<S>): schema is Migrated<S> {
return "current" in schema
}
export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
definition: S | Migrated<S>,
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
),
encode: SchemaGetter.transform((value) => encode(value)),
}),
Schema.decodeTo(Schema.toType(schema)),
)
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
return [[field.name, next]]
}),
)
}
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
return Option.isSome(decoded) ? decoded.value : initial
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}
export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields) {
return Schema.Struct(fields).mapFields(Struct.map(Schema.mutableKey))
}
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}
// Recover individual entries rather than discarding a whole history or collection.
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)
return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
decode: SchemaGetter.transform((items) => items.flatMap((item) => Option.toArray(decode(item)))),
encode: SchemaGetter.transform((items) => items.map((item) => encode(item))),
}),
),
() => [],
)
}
@@ -107,11 +107,6 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
test("workspace storage sanitizes Windows filename characters", () => {
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
+31 -80
View File
@@ -2,9 +2,11 @@ import { Platform, usePlatform } from "@/runtime/platform/platform"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "./schema"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
@@ -22,7 +24,6 @@ type PersistTarget = {
workspaceStorageAliases?: string[]
previousKey?: string
key: string
migrate?: (value: unknown) => unknown
}
const GLOBAL_STORAGE = "opencode.global.dat"
@@ -164,65 +165,10 @@ function write(storage: Storage, key: string, value: string) {
return ok
}
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
return JSON.stringify(merged)
}
function readCurrent(input: {
storage: SyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
function readCurrent(input: { storage: SyncStorage; key: string; normalize: (raw: string) => string | undefined }) {
const raw = input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
input.storage.removeItem(input.key)
return null
@@ -235,15 +181,14 @@ function relocateStoredValue(input: {
current: SyncStorage
sources: { storage: SyncStorage; key?: string }[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
for (const source of input.sources) {
const key = source.key ?? input.key
const raw = source.storage.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
source.storage.removeItem(key)
continue
@@ -259,12 +204,11 @@ function relocateStoredValue(input: {
async function readCurrentAsync(input: {
storage: AsyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
const raw = await input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
await input.storage.removeItem(input.key).catch(() => undefined)
return null
@@ -291,15 +235,14 @@ async function relocateStoredValueAsync(input: {
current: AsyncStorage
sources: { storage: AsyncStorage; key?: string }[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
for (const source of input.sources) {
const key = source.key ?? input.key
const raw = await source.storage.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
await removeAsync(source.storage, key)
continue
@@ -446,7 +389,6 @@ export function draftPersistedKeys() {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
resolveTarget,
windowStorage,
workspaceStorage,
@@ -529,15 +471,24 @@ export function removePersisted(
}
}
export function persisted<T>(
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
schema: S | Persistence.Migrated<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<T> {
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const defaults = snapshot(store[0])
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
}
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
@@ -567,14 +518,13 @@ export function persisted<T>(
const api: SyncStorage = {
getItem: (key) => {
const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
const value = readCurrent({ storage: current, key, normalize })
if (value !== undefined) return value
return relocateStoredValue({
current,
sources,
key,
defaults,
migrate: config.migrate,
normalize,
})
},
setItem: (key, value) => {
@@ -610,14 +560,13 @@ export function persisted<T>(
const api: AsyncStorage = {
getItem: async (key) => {
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
const value = await readCurrentAsync({ storage: current, key, normalize })
if (value !== undefined) return value
const relocated = await relocateStoredValueAsync({
current,
sources: relocationSources,
key,
defaults,
migrate: config.migrate,
normalize,
})
if (draftLatest === undefined) {
if (draft && relocated !== null) return (await current.getItem(key)) ?? relocated
@@ -644,9 +593,11 @@ export function persisted<T>(
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted<T, typeof store>(store, {
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
})
@@ -155,6 +155,16 @@ describe("isSessionNotFoundError", () => {
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
})
test("matches a structured error stored directly as the cause", () => {
const body = {
_tag: "SessionNotFoundError",
sessionID: "ses_missing",
message: "Session not found",
} satisfies SessionNotFoundError
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
})
test("rejects errors for other sessions and other 404 responses", () => {
const body = {
_tag: "SessionNotFoundError",
+3 -4
View File
@@ -36,10 +36,9 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
}
function unwrapNamedError(error: unknown): unknown {
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
return (error.cause as Record<string, unknown>).body
}
return error
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
return error.cause
}
// Client-synthesized session not-found errors share one constructor and
@@ -5,12 +5,12 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../sync"
import { ServerScope } from "@/runtime/server/scope"
import type { Data } from "@opencode-ai/client/solid"
import type { persisted } from "@/runtime/persistence/storage"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/runtime/persistence/storage").persisted = (_target, store) => [
store[0],
store[1],
const persist: typeof persisted = (_target, _schema, initial) => [
...createStore(initial),
null,
Object.assign(() => true, { promise: undefined }),
]
@@ -20,6 +20,7 @@ import { directoryKey, type DirectoryKey } from "./utils"
import type { ServerScope } from "@/runtime/server/scope"
import type { Data } from "@opencode-ai/client/solid"
import { normalizeAgentList, normalizeProviderList } from "./utils"
import { IconState, ProjectState, VcsState } from "../persistence"
export function createChildStoreManager(input: {
owner: Owner
@@ -155,29 +156,20 @@ export function createChildStoreManager(input: {
if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs"),
createStore({ value: undefined as VcsInfo | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "vcs"), VcsState, { value: undefined }),
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "project"),
createStore({ value: undefined as ProjectMeta | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "project"), ProjectState, { value: undefined }),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "icon"),
createStore({ value: undefined as string | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "icon"), IconState, { value: undefined }),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
@@ -3,17 +3,9 @@ import type { ReferenceInfo } from "@opencode-ai/client/promise"
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { IconState, ProjectState, VcsState } from "../persistence"
export type ProjectMeta = {
name?: string
icon?: {
override?: string
color?: string
}
commands?: {
start?: string
}
}
export type ProjectMeta = NonNullable<typeof ProjectState.Type.value>
export type State = {
status: "loading" | "partial" | "complete"
@@ -40,20 +32,20 @@ export type State = {
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
store: Store<typeof VcsState.Type>
setStore: SetStoreFunction<typeof VcsState.Type>
ready: Accessor<boolean>
}
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
store: Store<typeof ProjectState.Type>
setStore: SetStoreFunction<typeof ProjectState.Type>
ready: Accessor<boolean>
}
export type IconCache = {
store: Store<{ value: string | undefined }>
setStore: SetStoreFunction<{ value: string | undefined }>
store: Store<typeof IconState.Type>
setStore: SetStoreFunction<typeof IconState.Type>
ready: Accessor<boolean>
}
@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Persistence.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
test("migrates legacy auth and writes only current server objects", () => {
const schema = serverSchema()
const input = {
list: [
"http://localhost:4096",
{ url: "https://flat.example", username: "legacy", password: "first" },
{
type: "http",
displayName: "Remote",
label: "Production",
authToken: true,
http: { url: "https://nested.example", username: "legacy", password: "second" },
},
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Schema.decodeUnknownSync(schema)(input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
{ type: "http", http: { url: "https://flat.example", password: "first" } },
{
type: "http",
displayName: "Remote",
label: "Production",
authToken: true,
http: { url: "https://nested.example", password: "second" },
},
],
hidden: {},
projects: input.projects,
lastProject: {},
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = Schema.encodeSync(schema)(state)
expect(encoded).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = Schema.decodeUnknownSync(serverSchema())
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
expect(
decode({
list: [null, 1, {}, { type: "http", http: { url: 12 } }, "https://valid.example"],
projects: { local: [null, {}, { worktree: 1 }, { worktree: "/project" }], remote: false },
recentlyClosed: { local: [null, 1, "/closed"], remote: null },
}),
).toEqual({
...empty,
list: [{ type: "http", http: { url: "https://valid.example" } }],
projects: { local: [{ worktree: "/project", expanded: true }], remote: [] },
recentlyClosed: { local: ["/closed"], remote: [] },
})
})
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Schema.decodeUnknownSync(schema)({
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
{ worktree: "/remote", expanded: false },
],
other: [{ worktree: "/other", expanded: true }],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote", other: "/other" },
recentlyClosed: { local: ["/closed"], "https://opencode.example.com": ["/old-closed"] },
})
expect(state.projects).toEqual({
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
other: [{ worktree: "/other", expanded: true }],
})
expect(state.lastProject).toEqual({ local: "/local", other: "/other" })
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = Schema.decodeUnknownSync(schema)
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
}
expect(decode(input).projects).toEqual(input.projects)
props.canonicalLocalServer = "remote"
expect(decode(input).projects).toEqual({ local: [{ worktree: "/project", expanded: true }] })
expect(decode(input).lastProject).toEqual({ local: "/project" })
props.canonicalLocalServer = "local"
expect(decode(input).projects).toEqual(input.projects)
expect(input.lastProject).toEqual({ remote: "/project" })
})
test("migrates a last project without a project list", () => {
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
lastProject: { local: "/project" },
recentlyClosed: {},
})
})
})
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
user: [
null,
{ providerID: "provider", modelID: "model", visibility: "show", favorite: true },
{ providerID: "provider", modelID: "invalid", visibility: "invalid" },
{ providerID: "provider", modelID: "hidden", visibility: "hide" },
],
recent: [false, { providerID: "provider", modelID: "model" }, { providerID: "missing-model" }],
variant: { model: "high" },
})
expect(state).toEqual({
user: [
{ providerID: "provider", modelID: "model", visibility: "show", favorite: true },
{ providerID: "provider", modelID: "hidden", visibility: "hide" },
],
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
expect(decode({ value: { commands: { start: false } } })).toEqual({ value: undefined })
expect(decode({ value: {} })).toEqual({ value: {} })
const state = decode({
value: {
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
commands: { start: "bun dev" },
},
})
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
commands: { start: "bun dev" },
})
})
test("validates optional icon strings", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
})
test.skipIf(isServer)(
"persisted server relocation hydrates migrated state and writes current schema on updates",
async () => {
const values = new Map([
[
"default:server.v3",
JSON.stringify({
list: [{ url: "https://remote.example", username: "legacy", password: "secret" }],
projects: { "https://remote.example": [{ worktree: "/project", expanded: false }] },
lastProject: { "https://remote.example": "/project" },
}),
],
])
const root = createRoot((dispose) => ({
dispose,
state: persisted(
{ ...Persist.global("server"), previousKey: "server.v3" },
serverState(() => "https://remote.example"),
initial,
{
platform: "desktop",
windowID: "test",
openExternal() {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
storage: (name = "default") => ({
getItem: async (key) => values.get(`${name}:${key}`) ?? null,
setItem: async (key, value) => {
values.set(`${name}:${key}`, value)
},
removeItem: async (key) => {
values.delete(`${name}:${key}`)
},
}),
},
),
}))
try {
await root.state[3].promise
expect(values.has("default:server.v3")).toBe(false)
expect(root.state[0].list).toEqual([
{ type: "http", http: { url: "https://remote.example", password: "secret" } },
])
expect(root.state[0].projects).toEqual({ local: [{ worktree: "/project", expanded: false }] })
expect(root.state[0].lastProject).toEqual({ local: "/project" })
root.state[1]("projects", "local", 0, "expanded", true)
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
} finally {
root.dispose()
}
},
)
@@ -0,0 +1,136 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
})
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
http: ServerHttpBase,
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
})
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
})
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Persistence.migrate(
State,
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
}
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
}),
),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
})
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
}),
),
})
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
}),
),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
})
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
})
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
})
@@ -1,50 +1,25 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import {
migrateCanonicalLocalServerState,
migrateServerAuthState,
resolveServerList,
ServerConnection,
} from "./registry"
import { ServerScope } from "@/runtime/server/scope"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Persistence } from "@/runtime/persistence/schema"
describe("migrateServerAuthState", () => {
test("removes legacy usernames without changing passwords or other saved state", () => {
const state = {
list: [
"http://localhost:4096",
{ url: "https://flat.example", username: "legacy", password: "first" },
{
type: "http",
displayName: "Remote",
http: { url: "https://nested.example", username: "legacy", password: "second" },
},
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
expect(migrateServerAuthState(state)).toEqual({
...state,
list: [
"http://localhost:4096",
{ url: "https://flat.example", password: "first" },
{ type: "http", displayName: "Remote", http: { url: "https://nested.example", password: "second" } },
],
})
expect(state.list[1]).toHaveProperty("username", "legacy")
expect(migrateServerAuthState(migrateServerAuthState(state))).toEqual(migrateServerAuthState(state))
function serverSchema() {
return Persistence.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
lastProject: {},
recentlyClosed: {},
})
test("preserves absent or malformed lists", () => {
expect(migrateServerAuthState(undefined)).toBeUndefined()
expect(migrateServerAuthState({ projects: {} })).toEqual({ projects: {} })
expect(migrateServerAuthState({ list: [null, 1, {}] })).toEqual({ list: [null, 1, {}] })
})
})
}
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -64,17 +39,14 @@ describe("resolveServerList", () => {
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
expect(list[0] && String(ServerConnection.key(list[0]))).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
password: "saved",
},
],
stored: Schema.decodeUnknownSync(serverSchema())({
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
@@ -104,47 +76,42 @@ test("treats WSL sidecars as remote server connections", () => {
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
migrateCanonicalLocalServerState(
{
list: [],
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
lastProject: { "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
list: [],
projects: { local: [{ worktree: "/remote", expanded: true }] },
lastProject: { local: "/remote" },
})
})
test("preserves existing local state while merging a canonical web bucket", () => {
expect(
migrateCanonicalLocalServerState(
{
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
projects: {
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local" },
})
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Schema.decodeUnknownSync(serverSchema())({
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
"http://localhost:4096",
"http://localhost:4096/",
"http://127.0.0.1:4096",
])
const key = ServerConnection.Key.make("http://localhost:4096")
expect(canRemoveServer({ key, stored })).toBe(true)
expect(canRemoveServer({ key, stored, provided: [{ type: "http", http: { url: key } }] })).toBe(false)
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
const projects = createServerProjects({
store,
setStore,
scope: () => ServerScope.fromServerKey(props.server, props.canonicalLocalServer),
})
projects.open("/remote")
projects.collapse("/remote")
projects.touch("/remote")
expect(projects.list()).toEqual([{ worktree: "/remote", expanded: false }])
expect(projects.last()).toBe("/remote")
props.canonicalLocalServer = props.server
expect(projects.list()).toEqual([])
projects.open("/local")
projects.close("/local")
expect(projects.recentlyClosed()).toEqual(["/local"])
projects.open("/local")
expect(projects.recentlyClosed()).toEqual([])
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+20 -105
View File
@@ -1,18 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { pathKey } from "@/workspaces/path-key"
import { ServerScope } from "@/runtime/server/scope"
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
@@ -38,65 +32,12 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateServerAuthState(value: unknown) {
if (!isRecord(value) || !Array.isArray(value.list)) return value
return {
...value,
list: value.list.map((server) => {
if (!isRecord(server)) return server
const http = isRecord(server.http) ? server.http : server
if (!("username" in http)) return server
const next = { ...http }
delete next.username
return http === server ? next : { ...server, http: next }
}),
}
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
export function createServerProjects(input: {
scope: () => ServerScope
store: Store<T>
setStore: SetStoreFunction<T>
store: Store<ServerState>
setStore: SetStoreFunction<ServerState>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const setStore = input.setStore
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
@@ -162,22 +103,13 @@ export function createServerProjects<T extends ServerProjectState>(input: {
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
stored: ServerConnection.Http[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
for (const conn of input.stored) {
const key = ServerConnection.key(conn)
const existing = deduped.get(key)
@@ -196,28 +128,19 @@ export function resolveServerList(input: {
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
stored: ServerConnection.Http[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
return input.stored.some((server) => server.http.url === input.key)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
export type HttpBase = {
url: string
password?: string
}
export type HttpBase = typeof ServerHttpBase.Type
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Http = typeof ServerHttp.Type
export type Sidecar = {
type: "sidecar"
@@ -259,8 +182,8 @@ export namespace ServerConnection {
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const Key = ServerKey
export type Key = typeof Key.Type
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) =>
@@ -280,19 +203,11 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
...Persist.global("server"),
sync: true,
previousKey: "server.v3",
migrate: (value) => migrateCanonicalLocalServerState(migrateServerAuthState(value), props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
hidden: {} as Record<string, boolean>,
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
serverState(() => props.canonicalLocalServer),
{ list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} },
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
@@ -303,7 +218,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
const existing = store.list.findIndex((x) => x.http.url === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
@@ -314,7 +229,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => url(x) !== key)
const list = store.list.filter((x) => x.http.url !== key)
batch(() => {
setStore("list", list)
})
+6 -12
View File
@@ -12,6 +12,7 @@ import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -98,18 +99,11 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
})
function createGlobalModels() {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<{
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
recent: Array<{ providerID: string; modelID: string }>
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const [store, setStore, _, ready] = persisted(Persist.global("model"), ModelState, {
user: [],
recent: [],
variant: {},
})
const [recent] = createResource(
async () => {
const value = store.recent
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
import type { FileNode } from "@/runtime/server/types"
describe("buildFileTreeV2Model", () => {
@@ -45,6 +45,33 @@ describe("buildFileTreeV2Model", () => {
})
})
describe("sortFileTreeV2Paths", () => {
test("orders navigation depth-first with directories before sibling files", () => {
const paths = ["README.md", "src/a.ts", "src/lib/z.ts", "docs/guide.md", "src/lib/b.ts"]
expect(sortFileTreeV2Paths(paths)).toEqual([
"docs/guide.md",
"src/lib/b.ts",
"src/lib/z.ts",
"src/a.ts",
"README.md",
])
expect(paths[0]).toBe("README.md")
})
test("preserves original paths for selection", () => {
expect(sortFileTreeV2Paths(["README.md", "src\\lib\\a.ts", "/docs//guide.md/"])).toEqual([
"/docs//guide.md/",
"src\\lib\\a.ts",
"README.md",
])
})
test("handles an empty file list", () => {
expect(sortFileTreeV2Paths([])).toEqual([])
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
@@ -76,6 +76,12 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function sortFileTreeV2Paths(paths: readonly string[]) {
return flattenFileTreeV2(buildFileTreeV2Model(paths), () => true)
.filter((row) => row.node.type === "file")
.map((row) => row.node.originalPath)
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
expect(decode({ app })).toEqual({ app })
})
test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => {
expect(decode({ app })).toEqual({ app: "finder" })
})
test("defaults an absent selection", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,6 +5,8 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const OPEN_APPS = [
"vscode",
@@ -26,6 +28,10 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -163,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [prefs, setPrefs] = persisted(Persist.global("open.app"), OpenAppPreferences, { app: "finder" })
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
+1
View File
@@ -129,6 +129,7 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+12 -6
View File
@@ -5,18 +5,24 @@ import {
type SessionReviewExpandMode,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
sidebarOpened: true,
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
ReviewPanel,
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
platform,
)
// The filter is transient by design: a persisted filter would silently hide
+6 -2
View File
@@ -18,6 +18,7 @@ import type {
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/session/files/file-tree-v2"
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -83,6 +84,9 @@ export function ReviewPanelView(
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const navigationFiles = createMemo(() =>
searching() || props.fileList === "flat" ? filteredFiles() : sortFileTreeV2Paths(filteredFiles()),
)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
@@ -93,7 +97,7 @@ export function ReviewPanelView(
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = filteredFiles()
const files = navigationFiles()
if (active && files.includes(active)) return active
return files[0]
})
@@ -141,7 +145,7 @@ export function ReviewPanelView(
/>
}
activeFile={activeDiff()}
files={filteredFiles()}
files={navigationFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
+3 -2
View File
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
width: view().reviewPanel.width(),
available: available(),
split: splitReview(),
}),
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+57 -26
View File
@@ -11,10 +11,8 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active(),
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
}}
data-slot="session-chat-panel"
style={{
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
session.layout.view().reviewPanel.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
</div>
</Show>
</div>
+23 -1
View File
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
}
}
@@ -2,11 +2,14 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
let migrateTerminalState: (value: unknown) => unknown
let decodeTerminalState: (value: unknown) => unknown
let roundTripTerminalState: (value: unknown) => unknown
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
@@ -15,16 +18,13 @@ beforeAll(async () => {
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
migrateTerminalState = mod.migrateTerminalState
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
roundTripTerminalState = (value) =>
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -61,10 +61,10 @@ describe("getWorkspaceTerminalCacheKey", () => {
})
})
describe("migrateTerminalState", () => {
describe("TerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
decodeTerminalState({
active: "missing",
all: [
null,
@@ -85,7 +85,7 @@ describe("migrateTerminalState", () => {
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
decodeTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
@@ -100,4 +100,44 @@ describe("migrateTerminalState", () => {
],
})
})
test("defaults missing and malformed fields without dropping usable terminals", () => {
expect(decodeTerminalState({})).toEqual({ active: undefined, all: [] })
expect(decodeTerminalState({ active: 2, all: "invalid" })).toEqual({ active: undefined, all: [] })
expect(decodeTerminalState({ all: [null, {}, { id: "" }, { id: 2 }] })).toEqual({ active: undefined, all: [] })
expect(
decodeTerminalState({
all: [
{
id: "one",
title: "Terminal 3",
titleNumber: Infinity,
rows: "24",
cols: 80,
buffer: false,
cursor: NaN,
scrollY: 0,
},
{ id: "two", title: null, titleNumber: -1, buffer: "saved", cursor: 0 },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 3", titleNumber: 3, cols: 80, scrollY: 0 },
{ id: "two", title: "", titleNumber: 0, buffer: "saved", cursor: 0 },
],
})
})
test("round trips normalized terminal state", () => {
const value = {
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80, buffer: "output", cursor: 12, scrollY: 3 },
],
}
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+36 -77
View File
@@ -8,81 +8,51 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
export type LocalPTY = {
id: string
title: string
titleNumber: number
rows?: number
cols?: number
buffer?: string
scrollY?: number
cursor?: number
}
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
})
export type LocalPTY = typeof PTY.Type
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
})
const id = text(value.id)
if (!id) return
const title = text(value.title) ?? ""
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
}
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -131,18 +101,7 @@ function createWorkspaceTerminalSession(
) {
const location = { directory: sdk.directory }
const [store, setStore, _, ready] = persisted(
{
...terminalPersistTarget(scope, dir),
migrate: migrateTerminalState,
},
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
const [store, setStore, _, ready] = persisted(terminalPersistTarget(scope, dir), TerminalState, { all: [] })
const [ui, setUi] = createStore({
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
})
+4 -4
View File
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -45,9 +44,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -57,7 +56,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const height = createMemo(() => view().terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -238,10 +237,11 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
view().terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,6 +15,7 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -27,7 +28,9 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -478,7 +478,11 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="relative w-full h-full min-w-0"
data-workspace-session={props.workspaceSession() ? "" : undefined}
data-local-session={!props.workspaceSession() ? "" : undefined}
>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,7 +2,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -14,6 +15,13 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
})
function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
@@ -39,15 +47,12 @@ export function useUsageExceededDialogs() {
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
createStore({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
}),
)
const [goUpsellState, setGoUpsellState] = persisted(Persist.global("go-upsell"), GoUpsellState, {
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})
onCleanup(
sdk().event.on("session.status", (evt) => {
+163 -9
View File
@@ -1,16 +1,32 @@
import { describe, expect, test } from "bun:test"
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
settingsPersistence,
defaultSettings,
monoDefault,
monoFontFamily,
sansDefault,
sansFontFamily,
terminalFontFamily,
} from "./model"
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings reasoning mode migration", () => {
test.each([
[true, "full"],
[false, "compact"],
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
expect(migrateSettings(value)).toEqual({
...value,
general: { ...value.general, reasoningMode },
})
const settings = decode(value)
expect(settings.general.reasoningMode).toBe(reasoningMode)
expect(settings.general.showTerminal).toBe(true)
expect(settings.appearance.fontSize).toBe(16)
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
expect(value.general).not.toHaveProperty("reasoningMode")
})
@@ -19,13 +35,151 @@ describe("settings reasoning mode migration", () => {
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(migrateSettings(value)).toBe(value)
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
})
},
)
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
expect(migrateSettings(value)).toBe(value)
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
"defaults invalid or absent legacy settings: %j",
(general) => {
expect(decode({ general }).general.reasoningMode).toBe("compact")
},
)
test("migrates an undefined current mode but defaults an invalid current mode", () => {
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
"full",
)
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
"compact",
)
})
test("encodes only the current format and round trips migrated settings", () => {
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
const encoded = encode(settings)
expect(encoded).toEqual(settings)
expect(encoded).not.toHaveProperty("obsolete")
expect(encoded).not.toHaveProperty("general.obsolete")
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
expect(decode(encoded)).toEqual(settings)
})
})
describe("settings schema", () => {
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
expect(decode({})).toEqual({
general: {
autoSave: true,
releaseNotes: true,
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
keybinds: {},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
agentEnabled: true,
agent: "staplebops-01",
permissionsEnabled: true,
permissions: "staplebops-02",
errorsEnabled: true,
errors: "nope-03",
},
})
})
test("defaults invalid preferences locally while retaining valid siblings", () => {
const settings = decode({
general: {
showTerminal: true,
autoSave: false,
releaseNotes: undefined,
reasoningMode: 3,
followUpBehavior: "invalid",
},
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical" },
permissions: { autoApprove: true },
workspaces: { defaultDestination: "new", lastUsed: { good: "workspace", bad: true } },
keybinds: { good: "ctrl+k", bad: 3 },
notifications: { agent: false, permissions: "yes", errors: true },
sounds: { agent: "custom", agentEnabled: false, permissions: 3 },
})
expect(settings.general).toMatchObject({
showTerminal: true,
autoSave: false,
releaseNotes: true,
reasoningMode: "compact",
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
fontSize: 14,
mono: "Custom Mono",
sans: "",
terminal: "",
tabLayout: "vertical",
})
expect(settings.permissions.autoApprove).toBe(true)
expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } })
expect(settings.keybinds).toEqual({ good: "ctrl+k" })
expect(settings.notifications).toEqual({ agent: false, permissions: true, errors: true })
expect(settings.sounds).toMatchObject({ agent: "custom", agentEnabled: false, permissions: "staplebops-02" })
expect(decode(encode(settings))).toEqual(settings)
})
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
"defaults malformed sections without losing other sections: %j",
(invalid) => {
const defaults = decode({})
expect(
decode({
general: invalid,
appearance: { fontSize: 18 },
keybinds: invalid,
permissions: invalid,
workspaces: invalid,
notifications: invalid,
sounds: invalid,
}),
).toEqual({
...defaults,
appearance: { ...defaults.appearance, fontSize: 18 },
})
},
)
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
).toThrow()
})
})
+109 -95
View File
@@ -1,68 +1,20 @@
import { createStore, reconcile } from "solid-js/store"
import { reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export type TabLayout = "horizontal" | "vertical"
export interface NotificationSettings {
agent: boolean
permissions: boolean
errors: boolean
}
export interface SoundSettings {
agentEnabled: boolean
agent: string
permissionsEnabled: boolean
permissions: string
errorsEnabled: boolean
errors: string
}
export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showProjectIcon: boolean
showTerminal: boolean
reasoningMode: ReasoningMode
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
mobileDiffWrap: boolean
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
}
appearance: {
fontSize: number
mono: string
sans: string
terminal: string
tabLayout: TabLayout
}
keybinds: Record<string, string>
permissions: {
autoApprove: boolean
}
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings
sounds: SoundSettings
}
export type Settings = typeof settingsSchema.Type
export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"]
export type WorkspaceLastUsed = Settings["workspaces"]["lastUsed"][string]
export type TerminalPlacement = Settings["general"]["terminalPlacement"]
export type FollowUpBehavior = Settings["general"]["followUpBehavior"]
export type TabLayout = Settings["appearance"]["tabLayout"]
export type NotificationSettings = Settings["notifications"]
export type SoundSettings = Settings["sounds"]
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
@@ -116,7 +68,99 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const defaultSettings: Settings = {
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
const generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
releaseNotes: Schema.Boolean,
showFileTree: Schema.Boolean,
showNavigation: Schema.Boolean,
showSearch: Schema.Boolean,
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
})
const appearanceSchema = Persistence.struct({
fontSize: Schema.Number,
mono: Schema.String,
sans: Schema.String,
terminal: Schema.String,
tabLayout: Schema.Literals(["horizontal", "vertical"]),
})
const permissionsSchema = Persistence.struct({
autoApprove: Schema.Boolean,
})
const workspacesSchema = Persistence.struct({
defaultDestination: Schema.Literals(["last-used", "local", "new"]),
lastUsed: Persistence.record(
Schema.Literals(["local", "workspace"]).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
})
const notificationsSchema = Persistence.struct({
agent: Schema.Boolean,
permissions: Schema.Boolean,
errors: Schema.Boolean,
})
const soundsSchema = Persistence.struct({
agentEnabled: Schema.Boolean,
agent: Schema.String,
permissionsEnabled: Schema.Boolean,
permissions: Schema.String,
errorsEnabled: Schema.Boolean,
errors: Schema.String,
})
export const settingsSchema = Persistence.struct({
general: generalSchema,
appearance: appearanceSchema,
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
permissions: permissionsSchema,
workspaces: workspacesSchema,
notifications: notificationsSchema,
sounds: soundsSchema,
})
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
general: Persistence.optional(
Schema.Struct({
reasoningMode: Schema.optional(Schema.Unknown),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
return {
...value,
general: {
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
},
}
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
export const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
@@ -135,26 +179,11 @@ const defaultSettings: Settings = {
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: {
fontSize: 14,
mono: "",
sans: "",
terminal: "",
tabLayout: "horizontal",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
keybinds: {},
permissions: {
autoApprove: false,
},
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: {
agent: true,
permissions: true,
errors: false,
},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
agentEnabled: true,
agent: "staplebops-01",
@@ -169,26 +198,11 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
export function migrateSettings(value: unknown) {
if (!value || typeof value !== "object" || !("general" in value)) return value
const general = value.general
if (!general || typeof general !== "object") return value
if ("reasoningMode" in general && general.reasoningMode !== undefined) return value
if (!("showReasoningSummaries" in general) || typeof general.showReasoningSummaries !== "boolean") return value
return {
...value,
general: { ...general, reasoningMode: general.showReasoningSummaries ? "full" : "compact" },
}
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
gate: false,
init: () => {
const [store, setStore, , ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
createStore<Settings>(defaultSettings),
)
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
+8 -2
View File
@@ -5,7 +5,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -20,13 +21,18 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component = () => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
ModelProvidersSchema,
{ collapsed: {} },
)
const list = useFilteredList<ModelItem>({
@@ -1,12 +1,23 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import {
activeCommandRegistrations,
addCommandRegistration,
commandPaletteOptions,
CommandCatalog,
resolveKeybindOption,
type CommandOption,
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = Schema.decodeUnknownSync(CommandCatalog)
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
+14 -13
View File
@@ -2,6 +2,8 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -100,14 +102,17 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
title: string
description?: string
category?: string
keybind?: KeybindConfig
slash?: string
hidden?: boolean
}
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
key?: string
@@ -268,11 +273,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<CommandCatalog>({}),
)
const [catalog, setCatalog, _, catalogReady] = persisted(Persist.global("command.catalog.v1"), CommandCatalog, {})
const bind = (id: string, def: KeybindConfig | undefined) => {
const custom = settings.keybinds.get(actionId(id))
+90
View File
@@ -0,0 +1,90 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
+47
View File
@@ -0,0 +1,47 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -0,0 +1,34 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+20 -22
View File
@@ -1,7 +1,8 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import "./status/status-drawer.css"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -13,32 +14,29 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<Drawer
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
returnFocus={props.returnFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</Drawer.Close>
</MobileDrawerClose>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
)
}
@@ -1,7 +1,24 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { openNotificationSession } from "./notification"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Persistence } from "@/runtime/persistence/schema"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -1,14 +1,16 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode-ai/client/solid"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
@@ -17,24 +19,19 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
type NotificationBase = {
directory?: string
session?: string
metadata?: unknown
time: number
viewed: boolean
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
type TurnCompleteNotification = NotificationBase & {
type: "turn-complete"
}
type ErrorNotification = NotificationBase & {
type: "error"
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
type NotificationIndex = {
session: {
@@ -53,11 +50,7 @@ type NotificationIndex = {
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "addSessionTab" | "rememberSessionRoute" | "select">
export function openNotificationSession(
tabs: NotificationTabs,
server: ServerConnection.Key,
sessionID: string,
) {
export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
if (tab.type !== "session") return
tabs.rememberSessionRoute(tab, sessionID)
@@ -130,9 +123,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(input.sdk.scope, "notification"),
createStore({
list: [] as Notification[],
}),
NotificationStore,
{ list: [] },
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
@@ -230,10 +222,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (!session) return
if (session.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.agentEnabled()
) {
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
@@ -253,20 +242,12 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
})
}
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
eventID: string,
time: number,
) => {
const handleSessionError = (sessionID: string, error: SessionError.Error, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.errorsEnabled()
) {
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
@@ -1,7 +1,105 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
const defaults = decode({})
expect(defaults).toEqual({
sidebar: { opened: false, width: 344, workspaces: {}, workspacesDefault: false },
terminal: { height: 280, opened: false },
review: { diffStyle: "split", panelOpened: false },
fileTree: { opened: false, width: 200, tab: "changes" },
session: { width: 600 },
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server: ServerConnection.Key.make("local") } },
})
expect(
decode({
sidebar: { width: "bad" },
terminal: null,
session: { width: undefined },
review: { diffStyle: "bad" },
}),
).toEqual(defaults)
})
test("migrates old sidebar and panel settings and writes current fields", () => {
const value = decode({ sidebar: { workspaces: true }, review: {}, fileTree: { opened: true, width: 260 } })
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
test("preserves current panel preferences", () => {
const value = decode({
review: { diffStyle: "unified", panelOpened: false },
fileTree: { opened: true, width: 260, tab: "all" },
})
expect(value.review).toEqual({ diffStyle: "unified", panelOpened: false })
expect(value.fileTree).toEqual({ opened: true, width: 260, tab: "all" })
})
test("distinguishes an invalid panel field from an invalid review section", () => {
const fileTree = { opened: true, tab: "all" }
expect(decode({ review: { panelOpened: "bad" }, fileTree }).review.panelOpened).toBe(true)
expect(decode({ review: null, fileTree }).review.panelOpened).toBe(false)
})
test("preserves whole-record and whole-entry recovery for strict fields", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const scroll = { good: { x: 1, y: 2 }, bad: { x: "bad", y: 3 } }
expect(
decode({
sidebar: { workspaces: { good: true, bad: "bad" } },
sessionView: { [key]: { scroll, reviewMode: "git" } },
}),
).toMatchObject({
sidebar: { workspaces: {} },
sessionView: { [key]: { scroll: {}, reviewMode: "git" } },
})
expect(
decode({ sessionView: { [key]: { scroll: { good: { x: 1, y: 2 } }, reviewMode: "bad" } } }).sessionView,
).toEqual({ [key]: { scroll: {} } })
})
test("keeps scoped state and salvages valid tab entries", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const value = decode({
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
})
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
expect(value.sessionView).toEqual({ [key]: { scroll: {}, reviewOpen: ["a", "b"] } })
})
})
describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => {
const calls: string[] = []
+166 -145
View File
@@ -1,4 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -7,6 +8,8 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import type { Project } from "@/runtime/server/types"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
import { decode64 } from "@/runtime/persistence/base64"
import { same } from "@/runtime/persistence/equality"
import { createScrollPersistence, type SessionScroll } from "./scroll"
@@ -44,21 +47,22 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -123,6 +127,114 @@ export const useCurrentRoute = () => {
return createMemo(() => currentRoute(location.pathname, location.search))
}
const sessionTabsSchema = Persistence.struct({
all: Persistence.array(Schema.String),
active: Persistence.optional(Schema.String),
})
const sessionViewSchema = Persistence.struct({
scroll: Persistence.record(Schema.Struct({ x: Schema.Finite, y: Schema.Finite })),
reviewOpen: Schema.optional(Persistence.array(Schema.String)),
reviewMode: Schema.optional(Schema.Literals(["git", "branch", "turn"])),
reviewFile: Schema.optional(Schema.String),
pendingMessage: Schema.optional(Schema.String),
pendingMessageAt: Schema.optional(Schema.Finite),
})
export const layoutSchema = Persistence.struct({
sidebar: Persistence.struct({
opened: Schema.Boolean,
width: Schema.Finite,
workspaces: Persistence.record(Schema.Boolean),
workspacesDefault: Schema.Boolean,
}),
terminal: Persistence.struct({ height: Schema.Finite, opened: Schema.Boolean }),
review: Persistence.struct({
diffStyle: Schema.Literals(["unified", "split"]),
panelOpened: Schema.Boolean,
}),
fileTree: Persistence.struct({
opened: Schema.Boolean,
width: Schema.Finite,
tab: Schema.Literals(["changes", "all"]),
}),
session: Persistence.struct({ width: Schema.Finite }),
mobileSidebar: Persistence.struct({ opened: Schema.Boolean }),
sessionTabs: Persistence.record(Persistence.fallback(sessionTabsSchema, () => ({ all: [] }))),
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: TabStorage.ServerKey,
directory: Schema.optional(Schema.String),
}),
}),
})
export const layoutPersistence = Persistence.migrate(
layoutSchema,
Schema.Struct({
sidebar: Persistence.optional(
Schema.Struct({
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
workspacesDefault: Persistence.optional(Schema.Boolean),
}),
),
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
fileTree: Persistence.optional(
Schema.Struct({
opened: Persistence.optional(Schema.Boolean),
width: Persistence.optional(Schema.Finite),
tab: Persistence.optional(Schema.Literals(["changes", "all"])),
}),
),
sessionTabs: layoutSchema.fields.sessionTabs,
sessionView: layoutSchema.fields.sessionView,
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
...value,
sidebar:
typeof value.sidebar?.workspaces === "boolean"
? { ...value.sidebar, workspaces: {}, workspacesDefault: value.sidebar.workspaces }
: value.sidebar,
// Only an existing review section inherits the old file-tree panel flag.
review: value.review
? { ...value.review, panelOpened: value.review.panelOpened ?? value.fileTree?.opened }
: value.review,
fileTree:
value.fileTree && !value.fileTree.tab
? {
...value.fileTree,
opened: true,
width: value.fileTree.width === 260 ? DEFAULT_FILE_TREE_WIDTH : value.fileTree.width,
tab: "changes" as const,
}
: value.fileTree,
sessionTabs: Object.fromEntries(
Object.entries(value.sessionTabs)
.filter(([key]) => SessionStateKey.is(key))
.map(([key, tabs]) => [key, normalizeStoredSessionTabs(key, tabs)]),
),
sessionView: Object.fromEntries(Object.entries(value.sessionView).filter(([key]) => SessionStateKey.is(key))),
})),
encode: SchemaGetter.transform((value) => value),
}),
),
)
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
review: { diffStyle: "split", panelOpened: DEFAULT_REVIEW_PANEL_OPENED },
fileTree: { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" },
session: { width: DEFAULT_SESSION_WIDTH },
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server } },
}
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
@@ -130,137 +242,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const servers = useServers()
const platform = usePlatform()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const currentSessionState = (value: unknown) => {
if (!isRecord(value)) return value
const entries = Object.entries(value)
if (entries.every(([key]) => SessionStateKey.is(key))) return value
return Object.fromEntries(entries.filter(([key]) => SessionStateKey.is(key)))
}
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
const migratedSidebar = (() => {
if (!isRecord(sidebar)) return sidebar
if (typeof sidebar.workspaces !== "boolean") return sidebar
return {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
}
})()
const review = value.review
const fileTree = value.fileTree
const migratedFileTree = (() => {
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
tab: "changes",
}
})()
const migratedReview = (() => {
if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review
const opened =
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
return {
...review,
panelOpened: opened,
}
})()
const sessionTabs = currentSessionState(value.sessionTabs)
const sessionView = currentSessionState(value.sessionView)
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
let changed = false
const next = Object.fromEntries(
Object.entries(sessionTabs).map(([key, tabs]) => {
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
const current = {
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
active: typeof tabs.active === "string" ? tabs.active : undefined,
}
const normalized = normalizeStoredSessionTabs(key, current)
if (current.all.length !== tabs.all.length) changed = true
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
return [key, normalized]
}),
)
if (!changed) return sessionTabs
return next
})()
if (
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === value.sessionTabs &&
sessionView === value.sessionView
) {
return value
}
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
sessionView,
}
}
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6", migrate },
createStore({
sidebar: {
opened: false,
width: DEFAULT_SIDEBAR_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
height: DEFAULT_TERMINAL_HEIGHT,
opened: false,
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
},
fileTree: {
opened: false,
width: DEFAULT_FILE_TREE_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
width: DEFAULT_SESSION_WIDTH,
},
mobileSidebar: {
opened: false,
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
home: {
selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
},
}),
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(ServerConnection.key(servers.list[0])),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
@@ -508,7 +493,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>) {
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -519,11 +504,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -537,6 +535,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -566,6 +571,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -579,6 +592,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
@@ -1,109 +1,3 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -113,33 +7,3 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+2 -4
View File
@@ -1,9 +1,7 @@
import type { SessionTab, Tab } from "./tabs"
import type { TabStorage } from "./schema"
export type ClosedTab = {
tab: SessionTab
index: number
}
export type ClosedTab = typeof TabStorage.ClosedTab.Type
const CLOSED_TAB_LIMIT = 25
-50
View File
@@ -1,50 +0,0 @@
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if (!("server" in tab) || typeof tab.server !== "string") return []
const server = tab.server as ServerConnection.Key
if (
tab.type === "session" &&
typeof tab.sessionId === "string" &&
(tab.routeSessionId === undefined || typeof tab.routeSessionId === "string") &&
(tab.routeParentId === undefined || typeof tab.routeParentId === "string")
) {
return [
{
type: tab.type,
server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? {
routeSessionId: tab.routeSessionId,
...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}),
}
: {}),
},
]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string") &&
(tab.branch === undefined || typeof tab.branch === "string")
) {
return [
{
type: tab.type,
server,
draftID: tab.draftID,
directory: tab.directory,
worktree: tab.worktree,
branch: tab.branch,
},
]
}
return []
})
}
+62
View File
@@ -0,0 +1,62 @@
export * as TabStorage from "./schema"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const Session = Persistence.struct({
type: Schema.Literal("session"),
server: ServerKey,
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
})
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
server: ServerKey,
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
})
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
}),
)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
+55 -9
View File
@@ -2,11 +2,14 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -15,27 +18,66 @@ function sessionTab(sessionId: string): SessionTab {
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
).toEqual([sessionTab("a")])
})
test("drops persisted tabs without a server", () => {
expect(migrateTabs([{ type: "session", sessionId: "a" }])).toEqual([])
expect(decodeTabs([{ type: "session", sessionId: "a" }])).toEqual([])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null)).toEqual([])
expect(migrateTabs({})).toEqual([])
expect(decodeTabs(null)).toEqual([])
expect(decodeTabs({})).toEqual([])
})
test("preserves the active child route", () => {
expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
expect(decodeTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" },
])
})
test("drops an invalid child route", () => {
expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([])
expect(decodeTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([sessionTab("parent")])
expect(decodeTabs([{ ...sessionTab("parent"), routeSessionId: "child", routeParentId: 1 }])).toEqual([
{ ...sessionTab("parent"), routeSessionId: "child" },
])
})
test("encodes only canonical tabs and preserves drafts", () => {
const draft: Tab = { type: "draft", server, draftID: "draft", directory: "/project", branch: "main" }
const tabs = decodeTabs([
{ ...sessionTab("root"), routeSessionId: "root", routeParentId: "stale", legacy: true },
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
null,
]),
).toEqual([{ tab: sessionTab("a"), index: 1 }])
})
test("validates auxiliary tab state", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
).toEqual({ key: undefined })
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
})
})
@@ -48,8 +90,12 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+66 -42
View File
@@ -13,27 +13,12 @@ import { sessionHref } from "@/shell/routes/session"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { createDraftComposerState, type PromptModel } from "@/composer/state"
import { migrateTabs } from "./migration"
import { TabStorage } from "./schema"
import { useCurrentRoute } from "@/shell/state/layout"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
routeSessionId?: string
routeParentId?: string
}
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab
export type SessionTab = typeof TabStorage.Session.Type
export type DraftTab = typeof TabStorage.Draft.Type
export type Tab = typeof TabStorage.Tab.Type
export type PendingSession = {
draft: DraftTab
@@ -41,14 +26,10 @@ export type PendingSession = {
selection: ComposerSelection
}
export type TabInfo = {
title?: string
directory?: string
}
export type TabInfo = typeof TabStorage.Info.Type
type RecentTab = {
key?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
@@ -62,8 +43,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -71,25 +52,23 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const servers = useServers()
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: migrateTabs,
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo, , infoReady] = persisted(
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [store, setStore, _, ready] = persisted(Persist.window("tabs"), TabStorage.Tabs, [])
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), TabStorage.Recent, {
key: undefined,
})
const [info, setInfo, , infoReady] = persisted(Persist.window("tabs.info"), TabStorage.Infos, {})
const [panes, setPanes, , panesReady] = persisted(Persist.window("tabs.panes"), TabStorage.Panes, {})
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), TabStorage.Closed, [])
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
const params = useParams()
@@ -141,6 +120,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -153,6 +141,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -162,6 +151,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -198,6 +191,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -491,8 +485,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
},
})
+2 -82
View File
@@ -14,63 +14,13 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
margin-block-start: 8px;
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -79,36 +29,6 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+12 -22
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import Drawer from "@corvu/drawer"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<Drawer
<MobileDrawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,11 +423,9 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<Drawer.Trigger
<MobileDrawerTrigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -467,15 +465,11 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -493,7 +487,6 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -504,10 +497,7 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<button
type="button"
data-action="mobile-tabs-home"
@@ -546,9 +536,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
</div>
</MobileDrawerContent>
</MobileDrawer>
}
>
<Show
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Persistence } from "@/runtime/persistence/schema"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
})
@@ -1,17 +1,19 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
type Store = {
version?: string
}
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
})
type ParsedRelease = {
tag?: string
@@ -144,7 +146,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [store, setStore, _, ready] = persisted("highlights.v1", HighlightsStore, { version: undefined })
const [range, setRange] = createStore({
from: undefined as string | undefined,
+16 -12
View File
@@ -1,18 +1,22 @@
import type { FileContent } from "@/runtime/server/types"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
export type FileSelection = typeof FileSelection.Type
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
export type SelectedLineRange = typeof SelectedLineRange.Type
export type FileViewState = {
scrollTop?: number
+17 -10
View File
@@ -1,14 +1,26 @@
import { createEffect, createRoot } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { produce } from "solid-js/store"
import { Schema } from "effect"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types"
import { SelectedLineRange } from "./types"
import type { ServerScope } from "@/runtime/server/scope"
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
})
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return { ...range }
@@ -35,14 +47,9 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
}
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const [view, setView, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "file-view"),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const [view, setView, _, ready] = persisted(Persist.serverScoped(scope, dir, id, "file-view"), FileViewsSchema, {
file: {},
})
const meta = { pruned: false }

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