Compare commits

...
41 Commits
Author SHA1 Message Date
Aiden Cline 519cd8c771 feat(core): add grep matching options (#46716) 2026-09-01 21:27:37 -05:00
Luke Parker 34e40cc4bc fix(app): keep new local sessions in the selected directory (#46713) 2026-09-02 12:25:34 +10:00
Brendan Allan e0c7712f20 fix(app): keep background hint visible for at least one second (#46715) 2026-09-02 10:23:00 +08:00
Luke Parker ddda404d99 fix(desktop): bundle the CLI in production releases (#46705) 2026-09-02 12:15:25 +10:00
Luke Parker 0b771030ed fix(desktop): grant Windows sandbox access during installation (#46696) 2026-09-02 12:08:34 +10:00
Kit Langton 4d0eb97ddf feat(sdk): configure session-selected instances (#46496) 2026-09-01 22:07:08 -04:00
Luke Parker d117a33982 feat(app): show working when timeline progress is hidden (#46711) 2026-09-02 02:06:32 +00:00
Brendan Allan 46c33630b7 fix(server): authenticate only API requests (#46702) 2026-09-02 02:05:25 +00:00
Kit Langton 74eda7f950 chore(client): sort generated error statuses (#46708) 2026-09-02 01:44:53 +00:00
Dax 01093db365 feat(plugin): update plugins from the TUI dialog (#46699) 2026-09-01 21:41:55 -04:00
Kit Langton 95d788f8eb fix(cli): wait for consistent ACP model choices (#46613) 2026-09-01 21:35:51 -04:00
Luke Parker 49f9a60087 feat(app): support drafting during worktree creation (#46694) 2026-09-02 11:29:45 +10:00
Luke Parker 48927df2ff fix(app): separate location identity from sync failures (#46695) 2026-09-02 11:25:46 +10:00
Brendan Allan 2de99a2885 fix(app): apply safe-area sizing to iOS home-screen apps (#46703) 2026-09-02 09:15:08 +08:00
opencode-agent[bot] c8f81c8b83 chore: update nix node_modules hashes 2026-09-02 00:59:12 +00:00
opencode-agent[bot]andjlongster f92a725dfd fix(tui): scroll session while terminal is focused (#46697)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 20:54:14 -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]andAiden 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]andjlongster 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]andrekram1-node 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]andJames Long 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 Hillandjlongster 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]andnexxeln 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
350 changed files with 10472 additions and 14000 deletions
+1 -2
View File
@@ -417,7 +417,6 @@ jobs:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
@@ -480,7 +479,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Build
run: bun run build
+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
+1 -13
View File
@@ -3213,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=="],
@@ -5453,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=="],
@@ -5669,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=="],
@@ -5819,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=="],
@@ -5853,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=="],
@@ -6609,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=="],
+5
View File
@@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
cd packages/desktop
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
bun run build
npx electron-builder --dir \
--config electron-builder.config.ts \
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
"x86_64-linux": "sha256-Xjo7b+y3TG0fIoaiQ86tV98MGofO52aMGMBMlvDCNs0=",
"aarch64-linux": "sha256-9F6LHNOfzkyakz3GScvuzIsy0vCaQRuTZRdFSYHLanY=",
"aarch64-darwin": "sha256-U7TihrGM9l2vSU7Z6K2KinFvKQ5fBJyK4zXoDyZFChI=",
"x86_64-darwin": "sha256-JgenSPWgdxPjiHQiblhIM/+AL3oxrPm9zk18Fci19kk="
}
}
+6 -3
View File
@@ -1257,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)
@@ -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"}',
@@ -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()
@@ -9,12 +9,17 @@ const projectID = "proj_workspace_pending"
const draftID = "draft_workspace_pending"
const otherID = "ses_workspace_pending_other"
const text = "Create the workspace, then explain the pending session."
const followUp = "Then explain the setup scripts.\nInclude the install command."
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionPath = `/server/${base64Encode(server)}/session/`
const draftPath = `/new-session?draftId=${draftID}`
const headers = { "access-control-allow-origin": "*" }
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
test.use({
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
permissions: ["clipboard-read", "clipboard-write"],
})
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
@@ -24,22 +29,44 @@ for (const viewport of [
await page.setViewportSize(viewport)
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
const editor = page.locator('[data-component="composer-editor"]')
await draftFollowUp(page)
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await editor.press("Enter")
await editor.press("ControlOrMeta+Enter")
await page.locator('[data-component="composer"]').dispatchEvent("submit")
await expect(editor).toHaveText(followUp)
await expect(editor).toBeInViewport()
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("creating-worktree", {
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
contentType: "image/png",
})
if (viewport.name === "mobile") {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await tab.click()
await expect(drawer).toBeHidden()
await page.locator("html").evaluate((element) => {
element.dir = "rtl"
element.setAttribute("dir", "rtl")
})
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
expect(
await page
@@ -53,11 +80,14 @@ for (const viewport of [
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(pending.shimmer).toBeHidden()
await expect(spinner).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(pending.title).toHaveText("New session")
await expect(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
@@ -88,12 +118,133 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.shimmer).toHaveCount(0)
await expect(spinner).toHaveCount(1)
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.prompts).toHaveLength(1)
await page.locator('[data-action="composer-submit"]').click()
await expect
.poll(() => mock.prompts)
.toEqual([
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
])
})
}
for (const direction of ["ltr", "rtl"]) {
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
const mock = await openDraft(page)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
const before = await title.boundingBox()
const messageBefore = await pending.message.boundingBox()
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
const observation = await page.evaluateHandle(() => {
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
let frame = 0
const sample = () => {
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
const spinner = document.querySelector(
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
)
const editor = document.querySelector('[data-component="composer-editor"]')
frames.push({
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
})
frame = requestAnimationFrame(sample)
}
sample()
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(title).toHaveText("Created workspace session")
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
const frames = await observation.evaluate((observation) => observation.stop())
await observation.dispose()
expect(frames.length).toBeGreaterThan(0)
expect(
frames.filter(
(frame) =>
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
const messageAfter = await pending.message.boundingBox()
expect(after?.y).toBe(before?.y)
expect(after?.height).toBe(before?.height)
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
for (const failure of ["worktree", "session"]) {
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
const pending = await submitPending(page, mock)
await draftFollowUp(page)
mock.worktree.resolve(
failure === "worktree"
? { status: 500, json: { message: "Worktree creation failed" } }
: { status: 200, json: { directory: workspace } },
)
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(pending.shimmer).toHaveCount(0)
expect(mock.prompts).toEqual([])
})
}
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
route.fulfill({
status: 500,
json: { message: "Prompt admission failed" },
headers,
}),
)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
expect(mock.prompts).toEqual([])
})
test("restores the original draft when worktree creation fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
@@ -144,6 +295,7 @@ test("retains the draft and reuses the created workspace after session creation
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
@@ -164,13 +316,14 @@ test("restores the draft after closing and revisiting a pending session that fai
await expect(pending.shimmer).toBeVisible()
await expect(pending.shimmer).toContainText("Creating worktree")
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
@@ -182,6 +335,17 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
await expect(editor).toHaveText("!")
await expect(editor).toHaveAttribute("dir", "auto")
await editor.fill("")
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
await editor.press("ControlOrMeta+V")
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
@@ -254,6 +418,24 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
})
})
await page.route("**/api/agent?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
},
headers,
}),
)
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@@ -292,7 +474,11 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
const preparing = page.locator('[data-component="session-preparing"]')
const message = page.locator('[data-component="user-message"]')
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
const title = preparing.getByRole("heading", { level: 1 })
await expect(preparing).toBeVisible()
await expect(title).toHaveText("New session")
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
@@ -304,5 +490,5 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect.poll(() => mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
return { url, sessionID, messageID, message, shimmer }
return { url, sessionID, messageID, message, shimmer, title }
}
@@ -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", target: 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()
})
@@ -1,193 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
const directory = "/projects/deleted"
const destination = "/projects/restored"
const sessionID = "ses_missing_location"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
const events: OpenCodeEvent[] = []
const moves: unknown[] = []
await mockOpenCodeServer(page, {
directory: destination,
project: { ...fixture.project, worktree: destination },
provider: fixture.provider,
sessions: [session],
events: () => events.splice(0),
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
}),
})
await page.route("**/api/**", (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page.getByRole("status")).toContainText("Session location unavailable")
await expect(page.getByRole("status")).toContainText(directory)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
await expect(choose).toBeEnabled()
await choose.click()
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
await expect(dialog.getByRole("combobox")).toBeFocused()
await dialog.getByRole("combobox").press("Escape")
await expect(dialog).toHaveCount(0)
await expect(choose).toBeFocused()
expect(moves).toEqual([])
// The server can still reject a destination after it was selected in the picker.
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
return route.fulfill({
status: 400,
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
headers: { "access-control-allow-origin": "*" },
})
})
await choose.press("Enter")
await expect(dialog.getByRole("combobox")).toBeFocused()
await dialog.getByRole("combobox").fill(destination)
await dialog.getByRole("combobox").press("Enter")
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
await expect(choose).toBeEnabled()
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
await expect(choose).toBeFocused()
expect(moves).toEqual([{ directory: destination }])
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
session.directory = destination
events.push({
id: "evt_location_recovered",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await choose.click()
await dialog.getByRole("combobox").fill(destination)
await dialog.getByRole("combobox").press("Enter")
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
})
for (const create of [false, true]) {
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
page,
}) => {
const canonical = "/projects/repository"
const directory = "/projects/deleted-worktree"
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
const sessionID = "ses_worktree_recovery"
const events: OpenCodeEvent[] = []
const requests: { operation: string; body: unknown }[] = []
let listing = Promise.withResolvers<void>()
let listingRequested = Promise.withResolvers<void>()
const moving = Promise.withResolvers<void>()
const moveRequested = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: canonical,
project: { ...fixture.project, worktree: canonical },
provider: fixture.provider,
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
pageMessages: () => ({
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
}),
events: () => events.splice(0),
})
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
const headers = { "access-control-allow-origin": "*" }
if (url.searchParams.get("location[directory]") === directory)
return route.fulfill({ status: 500, body: "", headers })
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
if (route.request().method() === "GET") {
listingRequested.resolve()
await listing.promise
return route.fulfill({
json: [
{ directory: canonical, strategy: null },
{ directory: "/projects/existing-worktree", strategy: "git" },
...Array.from({ length: 20 }, (_, index) => ({
directory: `/projects/worktree-${index}`,
strategy: "git",
})),
],
headers,
})
}
if (route.request().method() === "POST") {
requests.push({ operation: "create", body: route.request().postDataJSON() })
return route.fulfill({
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
headers,
})
}
}
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
requests.push({ operation: "move", body: route.request().postDataJSON() })
moveRequested.resolve()
await moving.promise
events.push({
id: "evt_worktree_recovered",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
return route.fulfill({ status: 204, headers })
}
return route.fallback()
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await listingRequested.promise
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
listing.resolve()
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
await expect(existing).toBeVisible()
await expect(existing).toHaveCSS("height", "28px")
await page.keyboard.press("Escape")
await expect(page.getByRole("menu")).toHaveCount(0)
listing = Promise.withResolvers<void>()
listingRequested = Promise.withResolvers<void>()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await listingRequested.promise
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
listing.resolve()
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
await moveRequested.promise
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
moving.resolve()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
expect(requests).toEqual([
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
{ operation: "move", body: { directory: destination } },
])
})
}
@@ -0,0 +1,227 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
for (const endpoint of ["/api/location", "/api/agent"]) {
for (const recover of [false, true]) {
test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/working-tree"
const sessionID = "ses_location_sync_failure"
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Workspace sync" }],
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep working in this worktree", time: { created: 1 } }],
}),
})
let requests = 0
await page.route("**/api/**", (route) => {
const url = new URL(route.request().url())
if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory)
return route.fallback()
requests++
if (recover && requests > 1) return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
const failure = page.waitForResponse(
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
)
const settled = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === endpoint &&
url.searchParams.get("location[directory]") === directory &&
(recover ? response.ok() : requests === 3 && response.status() === 500)
)
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await failure
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(prompt).toBeEditable()
await prompt.fill("Continue after reconnecting")
await expect(prompt).toHaveText("Continue after reconnecting")
await settled
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Continue after reconnecting")
expect(requests).toBe(recover ? 2 : 3)
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
expect(recovery).toEqual([])
await page.screenshot({ path: test.info().outputPath("location-sync.png") })
})
}
}
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/old-tree"
const destination = "/projects/current-tree"
const sessionID = "ses_location_moved_while_loading"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Follow the session move", time: { created: 1 } }],
}),
})
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) {
requested.resolve()
await release.promise
}
return route.fallback()
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await requested.promise
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(prompt).toBeEditable()
await prompt.fill("Keep this draft")
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
id: "evt_location_moved_while_loading",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await resolved
const delayed = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok()
})
release.resolve()
await delayed
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Keep this draft")
await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible()
await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
expect(recovery).toEqual([])
})
test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/before-reconnect"
const destination = "/projects/after-reconnect"
const sessionID = "ses_location_reconnect"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Reconnected session" }
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Resume in the current worktree", time: { created: 1 } }],
}),
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(prompt).toBeEditable()
await prompt.fill("Draft before disconnect")
const connection = await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.close()
await transport.waitForConnection({ after: connection.id })
await resolved
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Draft before disconnect")
await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
expect(recovery).toEqual([])
})
test("ignores an old failed location read after reconnecting", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/reconnected-tree"
const sessionID = "ses_location_stale_response"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
fileList: () => [],
pageMessages: () => ({ items: [] }),
})
let requests = 0
await page.route("**/api/location?**", async (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
requests++
if (requests > 1) return route.fallback()
requested.resolve()
await release.promise
return route.fulfill({
status: 500,
body: "",
headers: { "access-control-allow-origin": "*" },
})
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await requested.promise
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(prompt).toBeEditable()
await prompt.fill("Keep typing here")
const connection = await transport.waitForConnection()
const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`)
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok()
)
})
await transport.close()
await transport.waitForConnection({ after: connection.id })
await metadata
release.resolve()
await resolved
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Keep typing here")
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
expect(requests).toBe(2)
expect(recovery).toEqual([])
})
function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
})
return requests
}
@@ -0,0 +1,278 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
directory,
partUpdated,
reasoningPart,
sessionID,
setupTimeline,
status,
stepStarted,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
test(`shows Working between busy and reasoning states at ${width}px`, async ({ page }, testInfo) => {
const timeline = await setupTimeline(page, {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
await expect(working).toHaveRole("status")
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeInViewport()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(working.locator('[data-component="text-shimmer"]')).toHaveCSS("line-height", "16px")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath(`working-${width}.png`) })
await timeline.send(stepStarted(assistantMessage([], { completed: false })))
await expect(working).toBeVisible()
const id = `prt_working_reasoning_${width}`
await timeline.send(partUpdated(reasoningPart(id, "")))
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(reasoningPart(id, "The inspection is complete.")))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeInViewport()
await timeline.send(status("idle"))
await expect(working).toHaveCount(0)
})
}
for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: { editToolPartsExpanded: true },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
const id = `prt_working_${name}`
const input =
name === "shell"
? { command: "printf ready" }
: name === "patch"
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool).toBeVisible()
await expect(working).toHaveCount(0)
const metadata =
name === "patch"
? {
files: [
{
file: "src/working.ts",
status: "added",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+export const ready = true",
},
],
}
: {}
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
await expect(tool).toContainText(
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
)
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
})
}
for (const name of ["read", "shell", "subagent"] as const) {
test(`keeps Working for grouped ${name} regardless of disclosure`, async ({ page }, testInfo) => {
await setupTimeline(page, {
viewport: { width: name === "shell" ? 390 : 1400, height: 900 },
messages: [
userMessage(),
assistantMessage(
[
toolPart("prt_grouped_previous", "read", "completed", { filePath: "package.json" }),
toolPart(
"prt_grouped_active",
name,
"running",
name === "shell"
? { command: "sleep 10" }
: name === "subagent"
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
: { filePath: "src/working.ts" },
),
],
{ completed: false },
),
],
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_grouped_previous,prt_grouped_active")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.locator('[data-component="session-background-hint"]')
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
}
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
if (name !== "subagent")
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
await expect(working).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
})
}
for (const grouped of [false, true]) {
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation for Working`, async ({ page }) => {
await setupTimeline(page, {
settings: { shellToolPartsExpanded: !grouped },
messages: [
userMessage(),
assistantMessage(
[
toolPart("prt_background_previous", "shell", "completed", { command: "echo ready" }),
toolPart(
"prt_background_active",
"shell",
"completed",
{ command: "sleep 10" },
{
metadata: { shellID: "sh_working_background", status: "running" },
},
),
],
{ completed: false },
),
],
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
if (!grouped) {
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
await expect(working).toHaveCount(0)
return
}
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(working).toBeVisible()
})
}
test("replaces Working with Retry and restores it on recovery", async ({ page }) => {
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
await timeline.send(status("retry"))
const retry = page.locator('[data-timeline-row="Retry"]')
await expect(retry).toContainText("Rate limited")
await expect(working).toHaveCount(0)
await timeline.send(stepStarted(assistant))
await expect(retry).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
})
test("hides Working while assistant text streams", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
await timeline.send({
id: "evt_working_text_started",
type: "session.text.started",
created: 1700000002000,
location: { directory },
durable: { aggregateID: sessionID, seq: 0, version: 1 },
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
})
await timeline.send({
id: "evt_working_text_delta",
type: "session.text.delta",
created: 1700000002001,
location: { directory },
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta: "The response is streaming." },
})
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toContainText(
"The response is streaming.",
)
await expect(working).toHaveCount(0)
})
for (const failed of [false, true]) {
test(`shows Working before prompt admission or execution events${failed ? " after an error" : ""}`, async ({
page,
}) => {
await setupTimeline(page, {
messages: [
userMessage(),
...(failed
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
: []),
],
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(0)
const release = Promise.withResolvers<void>()
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
await release.promise
return route.fallback()
})
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
)
try {
await editor.press("Enter")
const request = await requested
expect(request.postDataJSON()).toMatchObject({ text: "Check the working indicator immediately." })
await expect(working).toHaveRole("status")
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeInViewport()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
} finally {
release.resolve()
}
})
}
@@ -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", target: "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()
@@ -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 {
+3 -3
View File
@@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
event.currentTarget.value = ""
}}
/>
<Show when={state.popover.type !== "closed"}>
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
@@ -185,7 +185,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (!view.draftOnly && props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
@@ -238,7 +238,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
style={buttons()}
>
<ComposerEditorAddMenu
disabled={state.mode === "shell"}
disabled={view.draftOnly || state.mode === "shell"}
title={i18n.t("ui.promptInput.add")}
keybind={props.attachKeybind ?? ["Mod", "U"]}
attachLabel={i18n.t("ui.promptInput.attachments")}
@@ -29,6 +29,7 @@ export type ComposerSelectControl = {
}
export type ComposerEditorView = {
draftOnly?: boolean
placeholder?: Accessor<string>
add?: {
onAttach: () => void
@@ -332,6 +333,7 @@ export function createComposerEditor(input: {
draft.removeAttachment(id)
},
canSubmit() {
if (input.view.draftOnly) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
@@ -347,6 +349,7 @@ export function createComposerEditor(input: {
restoreFocus,
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
if (input.view.draftOnly) return
dispatch({ type: "input.changed", value, persist: !prompt })
},
onCursor(cursor: number) {
@@ -362,6 +365,7 @@ export function createComposerEditor(input: {
dispatch({ type: "mode.shell" })
},
submit(options?: { alternate?: boolean }) {
if (input.view.draftOnly) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
@@ -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 {
+12
View File
@@ -9,3 +9,15 @@ export function clonePrompt(prompt: Prompt): Prompt {
export function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
const start = promptLength(prompt)
const offset = start + 2
return [
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+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>
+17 -4
View File
@@ -1,4 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -10,24 +11,36 @@ export function createComposerSubmission(input: {
const initial = input.target
let target = input.target
let cleared: Prompt | undefined
let following: Prompt | undefined
let preserveDraft = false
return {
prompt: input.prompt,
context: input.context,
target: () => target,
clear() {
if (initial !== target) initial.reset()
target.reset()
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
cleared = target.current()
},
retarget(next: ComposerStateTarget) {
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
input.context.forEach((item) => next.context.add(item))
target = next
preserveDraft = options?.preserveDraft ?? false
},
current: (value: ComposerStateTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
return {
target,
prompt: following ? appendPrompt(input.prompt, following) : input.prompt,
context: input.context,
}
},
}
}
+6 -1
View File
@@ -231,6 +231,7 @@ describe("Composer submission", () => {
test("previews the first prompt while starting and hands it off before completing preparation", async () => {
const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture()
const promoted = createMemoryComposerState().capture()
const preview = Promise.withResolvers<SessionMessageUser>()
const ready = Promise.withResolvers<void>()
const calls: string[] = []
@@ -247,9 +248,10 @@ describe("Composer submission", () => {
controls,
working: () => false,
submitted() {},
async start(_selection, _submission, message) {
async start(_selection, submission, message) {
preview.resolve(message)
await ready.promise
submission.retarget(promoted, { preserveDraft: true })
return {
session: target,
cleanupReady: Promise.resolve(),
@@ -257,6 +259,8 @@ describe("Composer submission", () => {
expect(handoff).toHaveLength(1)
expect(handoff[0]?.id).toBe(message.id)
expect(handoff[0]?.text).toBe("prepare my worktree")
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
promoted.set([{ type: "text", content: "follow up", start: 0, end: 9 }], 9)
calls.push("complete")
},
}
@@ -271,6 +275,7 @@ describe("Composer submission", () => {
await submitted
expect(calls).toContain("complete")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "follow up", start: 0, end: 9 }])
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
+2 -1
View File
@@ -248,7 +248,8 @@ function restoreSubmission(
preview: item.preview,
})),
)
if (value.mode === "normal") {
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
restored.target.retry.set({
id: value.id,
agent: value.selection.agent,
+1
View File
@@ -73,6 +73,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
@@ -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),
+5
View File
@@ -32,6 +32,11 @@
}
}
/* iOS can report navigator.standalone without matching display-mode. */
#root[data-standalone="true"] {
height: 100vh;
}
@layer components {
[data-slot="session-side-panel-presence"][data-opened="true"] {
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
@@ -117,12 +117,13 @@ export function createNewSessionComposerAdapter(props: {
{ dir: base64Encode(sessionDirectory), id: created.id },
{ server: server.key, scope: serverSDK.scope },
),
{ preserveDraft: !!pending },
)
})
return {
cleanupReady,
complete: pending?.complete,
complete: pending ? () => pending.complete(submission.target()) : undefined,
session: {
id: created.id,
directory: sessionDirectory,
@@ -194,6 +195,7 @@ async function resolveSessionDirectory(input: {
return createWorktree({
api: input.serverSDK.api,
data: input.data,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
+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,10 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
normalizeNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -12,8 +7,6 @@ describe("new session workspace selection", () => {
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
directory: "/project/feature",
projectWorktree: "/project",
}),
).toBe("main")
})
@@ -22,31 +15,21 @@ describe("new session workspace selection", () => {
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("/project")
})
test("normalizes main to the project root outside the main worktree", () => {
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
test("keeps local selection when the cached project path is stale", () => {
const input = { enabled: true, directory: "C:/Projects/repo", projectWorktree: "D:/Projects/repo" }
expect(resolveNewSessionWorktree(input)).toBe("main")
expect(resolveNewSessionWorktree({ ...input, selected: "/worktree" })).toBe("/worktree")
})
test("resolves the branch from the active location", () => {
@@ -11,27 +11,15 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
if (value === "main" && projectWorktree && !sameDirectory(directory, projectWorktree)) return projectWorktree
return value
return input.fallback ?? "main"
}
export function resolveNewSessionBranch(input: {
@@ -92,8 +80,6 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionWorktree({
enabled: visible(),
selected: selected(),
directory: sdk().directory,
projectWorktree: currentProject()?.worktree,
fallback: fallback(),
}),
)
@@ -145,7 +131,7 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
input.setSelectedWorktree(worktree)
remember(worktree)
},
create: (branch: string) => {
@@ -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", target: "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"])
})
})
+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<{
+30 -15
View File
@@ -1,25 +1,40 @@
import { createMemo, type Accessor } from "solid-js"
import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
minimumDuration = 0,
) {
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 [tick, setTick] = createSignal(0)
const animation = createMemo<{
identity?: unknown
show: boolean
animate: boolean
value: T | undefined
started: number
}>((previous) => {
tick()
const currentIdentity = identity?.()
const current = value()
const same = !identity || previous?.identity === currentIdentity
const started = same && previous?.show ? previous.started : performance.now()
const remaining =
current === undefined && same && previous?.show ? minimumDuration - (performance.now() - started) : 0
const show = current !== undefined || remaining > 0
if (remaining > 0) {
const timer = setTimeout(() => setTick((value) => value + 1), remaining)
onCleanup(() => clearTimeout(timer))
}
return {
identity: currentIdentity,
show,
started,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
+2
View File
@@ -694,6 +694,7 @@ export const dict = {
"session.queue.remove": "Remove",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments": "+ attachments",
"session.timeline.working": "Working",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
@@ -744,6 +745,7 @@ export const dict = {
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
"session.new.worktree.creating": "Creating worktree",
"session.new.worktree.draftPlaceholder": "Draft a follow-up while the worktree is being prepared...",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",
+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,
})
@@ -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("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("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("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,136 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { getFilename } from "@opencode-ai/util/path"
import { createMutation } from "@tanstack/solid-query"
import { createEffect, createResource, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { createWorktree } from "@/workspaces/create"
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
const language = useLanguage()
const sdk = useServerSDK()
const data = useData()
const dialog = useDialog()
const pickDirectory = useDirectoryPicker()
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
const project = () => data.project.get(props.projectID)
const [worktrees] = createResource(
() => (state.worktreesOpen ? props.projectID : undefined),
async (projectID) => {
try {
await sdk.api.worktree.refresh({ projectID })
return await sdk.api.worktree.list({ projectID })
} catch {
showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
return []
}
},
// Seed latest so even the first fetch does not enter Suspense.
{ initialValue: [] },
)
let button: HTMLButtonElement | undefined
const move = createMutation(() => ({
mutationFn: async (input: { sessionID: string; directory?: string }) => {
// A deleted worktree cannot resolve its own location. Create from the
// project's saved canonical checkout instead.
const current = project()
const destination =
input.directory ??
(current &&
(await createWorktree({
api: sdk.api,
directory: current.canonical,
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
})))
if (!destination) return
await sdk.api.session.move({ sessionID: input.sessionID, directory: destination })
},
onError: (error) => {
setState("restoreFocus", true)
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
},
}))
createEffect(() => {
if (!state.restoreFocus || move.isPending || dialog.active) return
setState("restoreFocus", false)
button?.focus()
})
function choose() {
if (move.isPending) return
const sessionID = props.sessionID
pickDirectory({
server: sdk.server,
title: language.t("session.location.choose"),
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) move.mutate({ sessionID, directory })
if (!directory) setState("restoreFocus", true)
},
})
}
return (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
</div>
</div>
</DockShell>
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>
)
}
+7 -30
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
import { createEffect, on, onMount } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
@@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
import { SessionLocationMissing } from "./location-missing"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -220,14 +219,6 @@ export function ActiveSessionComposerRegion(props: {
onResponseSubmit: () => void
}) {
const settings = useSettings()
const location = useWorkspaceLocation()
const missing = createMemo(() => {
const error = location().error
const current = props.session.data.info()?.location
if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID)
return current.directory
return undefined
})
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -256,26 +247,12 @@ export function ActiveSessionComposerRegion(props: {
<SessionComposerRegion
controller={region}
composer={
<Show
when={missing()}
keyed
fallback={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
</div>
</div>
}
>
{(directory) => (
<SessionLocationMissing
sessionID={requireSessionID(props.session)}
projectID={props.session.data.info()!.projectID}
directory={directory}
/>
)}
</Show>
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
</div>
</div>
}
/>
)
@@ -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,
@@ -0,0 +1,55 @@
import { createEffect, on, onCleanup } from "solid-js"
import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
store: () => props.pending.composer.store,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
onEditor: (value) => {
element = value
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
createEffect(
on(
() => props.pending,
(pending) => {
const pathname = location.pathname
editor.restoreFocus(pending.composer.cursor())
onCleanup(() => {
if (document.activeElement !== element) return
const cursor = pending.composer.cursor()
requestAnimationFrame(() => {
if (location.pathname !== pathname) return
const next = document.querySelector<HTMLDivElement>('[data-component="composer-editor"]')
if (!next || next === element) return
next.focus()
setCursorPosition(next, cursor ?? 0)
})
})
},
),
)
return (
<div data-component="session-composer-dock" class="w-full shrink-0 bg-v2-background-bg-base pb-3">
<div class="mx-auto w-full max-w-[1000px] px-3">
<ComposerEditor controller={editor} modelControlsVisible={false} />
</div>
</div>
)
}
+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 -3
View File
@@ -26,6 +26,7 @@ import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreen } from "./screen"
import { PreparingComposer } from "./preparing-composer"
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
@@ -64,7 +65,8 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
}}
>
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pt-5 pb-5 md:px-5">
<SessionIdentityHeader sessionID={props.sessionID} />
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
<SessionUserMessage
sessionID={props.sessionID}
message={props.pending.message}
@@ -84,6 +86,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
</div>
</div>
</div>
<PreparingComposer pending={props.pending} />
</DataProvider>
</SessionStatePanel>
)
@@ -116,7 +119,7 @@ function ResolvedTargetSessionRoute() {
const current = createSessionResolution(
() => params.id,
() => data.session,
{ children: true },
{ children: true, connected: () => server.ctx.sdk.connection.status() === "connected" },
)
const directory = createMemo(() => current()?.location.directory)
@@ -133,7 +136,7 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value}>
<LocationProvider directory={value} workspaceID={() => current()?.location.workspaceID}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
@@ -4,6 +4,7 @@ import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
@@ -27,6 +28,8 @@ export function SessionTitleHeader(props: ParentProps) {
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
const settings = useSettings()
const navigate = useNavigate()
const tab = createMemo(() =>
@@ -54,10 +57,18 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
return sessionTitle(
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
)
})
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
const directory = createMemo(
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
)
const title = createMemo(() =>
pending()
? language.t("command.session.new")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
@@ -70,7 +81,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
})
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()
@@ -34,7 +34,7 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
export function createSessionResolution<T>(
sessionID: () => string | undefined,
sessions: () => SessionStore<T>,
options?: { children?: boolean },
options?: { children?: boolean; connected?: () => boolean },
) {
const cached = createMemo(() => {
const id = sessionID()
@@ -46,15 +46,15 @@ export function createSessionResolution<T>(
// Start independent reads before constructing the selected view, including
// when its metadata is cached but its transcript has never been loaded.
createRenderEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
on([sessionID, sessions, () => options?.connected?.() ?? true] as const, ([id, store, connected]) => {
if (!id || !connected) return
let stale = false
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
void store.message.sync(id).catch(() => undefined)
if (cached() && !options?.children) {
if (cached() && !options?.children && !options?.connected) {
setStatus({ id, store, state: "settled" })
return
}
@@ -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,
})
@@ -539,33 +539,96 @@ function MessageTimelineView(
.findLast((ref) => blocking.has(ref.partID))?.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
const backgroundHintPresence = createAnimatedPresence(
backgroundHintPartID,
() => backgroundHintRef() ?? null,
sessionID,
1000,
)
const showWorking = createMemo(() => {
const id = sessionID()
if (!id || sessionStatus().type !== "busy") return false
if (data.session.permission.list(id)?.length || data.session.form.list(id)?.length) return false
const active = projection.activeMessageID()
if (!active) return false
const assistant = projection.assistantMessagesByParent().get(active)?.at(-1)
if (assistant?.retry) return false
// Pending steers still project under the previous response until delivery.
// Its error must not hide feedback for a newly submitted prompt.
if (
assistant?.error &&
!data.session.pending.list(id).some((item) => item.type === "user" && item.delivery === "steer")
)
return false
const content = assistant?.content.at(-1)
if (
assistant?.time.completed === undefined &&
assistant?.time.streamed === undefined &&
content?.type === "text" &&
content.text.trim()
)
return false
const background = new Set(props.background.tasks().map((task) => task.id))
return !projection.rows().some((row) => {
if (row.userMessageID !== active) return false
if (row._tag === "Thinking") return true
if (row._tag === "Notice") {
const message = messageByID().get(row.messageID)
return message?.type === "compaction" && message.status === "running"
}
// Used groups keep the fallback regardless of disclosure state.
if (row._tag !== "AssistantPart" || row.group.type === "context") return false
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
if (content?.type !== "tool") return false
if (content.state.status === "streaming" || content.state.status === "running") return true
const taskID = content.state.metadata?.[content.name === "subagent" ? "sessionID" : "shellID"]
return background.has(content.id) || (typeof taskID === "string" && background.has(taskID))
})
})
})
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<>
<Show when={showWorking()}>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
data-component="session-working"
role="status"
classList={{
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<BackgroundMoveHint />
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</div>
</div>
</Show>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
}
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
@@ -60,6 +60,7 @@ export function SessionWorkspaceMenu(props: {
selection === "create"
? await createWorktree({
api: sdk.api,
data,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
})
@@ -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))
@@ -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[] = []
+117 -142
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,20 +47,11 @@ 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>
@@ -133,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,
@@ -140,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,
+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)
+49 -7
View File
@@ -3,10 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
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()
})
})
+31 -48
View File
@@ -12,47 +12,31 @@ import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
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 {
createDraftComposerState,
createMemoryComposerState,
type ComposerState,
type PromptModel,
} from "@/composer/state"
import { appendPrompt, promptLength } from "@/composer/prompt-parts"
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
message: SessionMessageUser
selection: ComposerSelection
composer: ComposerState
}
export type TabInfo = {
title?: string
directory?: string
}
export type TabInfo = typeof TabStorage.Info.Type
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
type RecentTab = {
key?: string
}
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
@@ -85,23 +69,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
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 [panes, setPanes, , panesReady] = persisted(
Persist.window("tabs.panes"),
createStore<Record<string, TabPaneState>>({}),
)
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()
@@ -318,8 +292,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const draft = { ...actions.draft(draftID) }
const next = { type: "session" as const, ...session }
const key = tabKey(next)
const composer = createMemoryComposerState()
const ready = startTransition(() => {
setPending(key, { draft, ...preview })
setPending(key, { draft, ...preview, composer })
const index = store.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index === -1) return
const active = location.pathname === "/new-session" && location.query.draftId === draftID
@@ -334,9 +309,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
return {
ready,
async complete() {
async complete(target: ReturnType<ComposerState["capture"]>) {
await ready
if (!pending[key]) return
await memory.get<ComposerState>(key, "prompt")?.ready.promise
if (promptLength(composer.current())) target.set(composer.current(), composer.cursor())
await startTransition(() => setPending(key, undefined))
memory.remove(tabKey(draft))
removeDraftPersisted(draftID)
@@ -344,6 +321,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
async rollback(worktree?: string) {
await ready
if (!pending[key]) return
const original = memory.get<ComposerState>(tabKey(draft), "prompt")
if (original && promptLength(composer.current())) {
// Nothing was submitted: recover both inputs in the original draft.
const restored = appendPrompt(original.current(), composer.current())
original.set(restored, promptLength(restored))
}
await startTransition(() => {
const index = store.findIndex((tab) => tabKey(tab) === key)
if (index !== -1) {
+10 -1
View File
@@ -11,6 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
@@ -25,6 +26,7 @@ export function TabNavItem(props: {
href: string
server: ServerConnection.Key
session: SessionInfo | undefined
preparing: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@@ -239,7 +241,14 @@ export function TabNavItem(props: {
when={props.session}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
<Show
when={props.preparing}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
<SessionProgressIndicatorV2 />
</Show>
}
>
{(session) => (
@@ -27,6 +27,7 @@ function SessionTabSlot(props: {
active: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
preparing: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -62,6 +63,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={props.session}
preparing={props.preparing}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
@@ -167,6 +169,7 @@ function SessionTabEntry(props: {
active={props.active}
orientation={props.orientation}
session={session()}
preparing={!!pending()}
fallbackTitle={
pending()
? language.t("command.session.new")
+18 -6
View File
@@ -28,6 +28,7 @@ import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
@@ -171,11 +172,15 @@ export function Titlebar(props: {
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const preparing = createMemo(() => {
const route = layout.route()
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
})
const [loadedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
if (preparing()) return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
@@ -184,7 +189,7 @@ export function Titlebar(props: {
const session = createMemo(() => {
const route = layout.route()
if (route.type !== "session") return
if (tabs.pendingSession(route.server, route.sessionId)) return
if (preparing()) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
if (cached) return cached
@@ -441,10 +446,17 @@ export function Titlebar(props: {
tab().type === "draft" ? (
<Icon name="edit" />
) : (
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
<Show
when={preparing()}
fallback={
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
}
>
<SessionProgressIndicatorV2 />
</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,
+53 -24
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { createData } from "@opencode-ai/client/solid"
import { createRoot } from "solid-js"
import { createWorktree } from "./create"
describe("worktree creation", () => {
@@ -44,28 +46,44 @@ describe("worktree creation", () => {
),
})
expect(
await createWorktree({
api,
await createRoot(async (dispose) => {
const data = createData({
api: () => api,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
event: { on: () => () => {}, listen: () => () => {} },
})
try {
expect(
await createWorktree({
api,
data,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
expect(data.location.info({ directory: "/created" })).toEqual({ directory: "/created", project })
const count = requests.length
await data.location.syncInfo({ directory: "/created" })
expect(requests).toHaveLength(count)
} finally {
dispose()
}
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
})
test("does not fall back to a shared project when location lookup fails", async () => {
@@ -81,10 +99,21 @@ describe("worktree creation", () => {
),
})
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
await createRoot(async (dispose) => {
const data = createData({
api: () => api,
directory: "/copies/repo",
event: { on: () => () => {}, listen: () => () => {} },
})
try {
await expect(createWorktree({ api, data, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
} finally {
dispose()
}
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
})
})
+4 -1
View File
@@ -1,8 +1,10 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
data: Pick<Data, "location">
directory: string
project?: LocationGetOutput["project"]
branch?: string
@@ -15,6 +17,7 @@ export async function createWorktree(input: {
branch: input.branch,
directory: getDirectory(project.canonical),
})
await input.api.location.get({ location: { directory: created.directory } })
// Populate the client cache before the destination session mounts.
await input.data.location.syncInfo({ directory: created.directory })
return created.directory
}
+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 }
+20 -21
View File
@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
import { retry } from "@opencode-ai/util/retry"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
export type { LocationContext } from "@/runtime/server/client"
@@ -8,7 +9,6 @@ export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
readonly ref: LocationRef
readonly current: LocationGetOutput | undefined
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
}
const context = createSimpleContext({
@@ -16,29 +16,29 @@ const context = createSimpleContext({
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const data = useData()
const ref = createMemo(() => ({
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
}))
const ref = createMemo(
() => ({
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
}),
undefined,
{
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
},
)
const current = createMemo(() => data.location.info(ref()))
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
let generation = 0
createEffect(() => {
const location = ref()
if (serverSDK.connection.status() !== "connected") return
const attempt = ++generation
setError(undefined)
void data.location.sync(location).catch((cause) => {
const latest = ref()
if (
generation !== attempt ||
latest.directory !== location.directory ||
latest.workspaceID !== location.workspaceID
)
return
setError({ location, cause })
let stale = false
onCleanup(() => {
stale = true
})
if (serverSDK.connection.status() !== "connected") return
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
retryIf: () => !stale,
}).catch(() => undefined)
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
@@ -46,7 +46,6 @@ const context = createSimpleContext({
...location(),
ref: ref(),
current: current(),
error: error(),
}))
},
})
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { persisted } from "@/runtime/persistence/storage"
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
const key = `consumer-model-selection-${crypto.randomUUID()}`
localStorage.setItem(
key,
JSON.stringify({ pick: { session1: { agent: "plan" }, __workspace__: { agent: "build" } } }),
)
createRoot((dispose) => {
try {
const [state, setState] = persisted(
key,
ModelSelectionSchema,
{ session: {} },
{
platform: "web",
openExternal: () => {},
restart: async () => {},
notify: async () => {},
},
)
expect(state.session.session1?.agent).toBe("plan")
setState("session", "session1", { agent: "build", variant: null })
expect(state.session.session1?.agent).toBe("build")
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
session: { session1: { agent: "build", variant: null } },
})
} finally {
dispose()
localStorage.removeItem(key)
}
})
})
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
@@ -34,12 +34,96 @@ const platform: Platform = {
}
describe("prompt persistence", () => {
test.each([null, "null", '"invalid"', "not json"])(
"keeps dynamic initial input with unavailable stored state: %s",
async (raw) => {
const store = createDraftStore({
get: async () => raw,
set: async () => undefined,
remove: async () => undefined,
putBlob: async () => "unused",
getBlob: async () => null,
})
const model = { providerID: "provider", modelID: "model", variant: "high" }
const root = createRoot((dispose) => ({
dispose,
session: createComposerState(
ServerScope.local,
{ draftID: `draft-initial-${raw}` },
{ prompt: "initial prompt", model },
{ ...platform, draftStore: store },
),
}))
await root.session.ready.promise
expect(root.session.current()).toEqual([{ type: "text", content: "initial prompt", start: 0, end: 14 }])
expect(root.session.cursor()).toBe(14)
expect(root.session.model.current()).toEqual(model)
root.dispose()
},
)
test("decodes hydrated images and writes canonical blob references through draft storage", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async (blob) => {
blobs.set("composer-image", blob)
return "composer-image"
},
getBlob: async (id) => blobs.get(id) ?? null,
})
const target = Persist.draft("draft-schema-image", "prompt")
const key = `${target.storage}:${target.key}`
await store.setItem(
key,
JSON.stringify({
prompt: [
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
dataUrl: "data:image/png;base64,YQ==",
},
],
}),
)
const root = createRoot((dispose) => ({
dispose,
session: createComposerState(ServerScope.local, { draftID: "draft-schema-image" }, undefined, {
...platform,
draftStore: store,
}),
}))
await root.session.ready.promise
expect(root.session.current()).toEqual([
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
])
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
await Bun.sleep(0)
expect(documents.get(key)).toContain("hello")
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
expect(documents.get(key)).not.toContain("dataUrl")
expect(documents.get(key)).not.toContain("blob:")
root.dispose()
})
test("relocates a previous key into canonical storage", () => {
localStorage.setItem("server.v3", JSON.stringify({ list: ["https://example.com"] }))
const [state] = persisted(
{ ...Persist.global("server"), previousKey: "server.v3" },
createStore({ list: [] as string[] }),
Schema.Struct({ list: Schema.mutable(Schema.Array(Schema.String)) }),
{ list: [] },
platform,
)
@@ -70,4 +70,22 @@ describe("prompt submission state", () => {
expect(submission.restore()).toBeUndefined()
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
})
test("preserves a prepared follow-up and recovers both inputs when the first send fails", () => {
const draft = createMemoryComposerState({ prompt: "first prompt" })
const session = createMemoryComposerState({ prompt: "follow-up" })
const submission = createComposerSubmission({ target: draft, prompt: draft.current(), context: [] })
submission.retarget(session, { preserveDraft: true })
submission.clear()
expect(draft.current()[0]).toMatchObject({ content: "" })
expect(session.current()[0]).toMatchObject({ content: "follow-up" })
expect(submission.restore()?.prompt).toEqual([
{ type: "text", content: "first prompt", start: 0, end: 12 },
{ type: "text", content: "\n\n", start: 12, end: 14 },
{ type: "text", content: "follow-up", start: 14, end: 23 },
])
session.set([{ type: "text", content: "edited", start: 0, end: 6 }])
expect(submission.restore()).toBeUndefined()
})
})
@@ -19,6 +19,18 @@ test("normal browser windows are not standalone", () => {
expect(isStandalone()).toBe(false)
})
test("detects iOS home-screen apps when the standalone media query does not match", () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, "standalone")
Object.defineProperty(navigator, "standalone", { configurable: true, value: true })
try {
expect(window.matchMedia("(display-mode: standalone)").matches).toBe(false)
expect(isStandalone()).toBe(true)
} finally {
if (descriptor) Object.defineProperty(navigator, "standalone", descriptor)
if (!descriptor) Reflect.deleteProperty(navigator, "standalone")
}
})
test("restores the last PWA route including query and hash without adding history", () => {
window.history.replaceState({ retained: true }, "", "http://localhost/")
const length = window.history.length
@@ -66,3 +66,41 @@ test("enables sidebar motion only after custom width hydration", async () => {
})
})
})
test("recovers malformed preferences independently and keeps the filter transient", async () => {
const root = createPanel()
root.state.setFilter("transient")
read?.(JSON.stringify({ sidebarOpened: false, sidebarWidth: "wide", expandMode: "invalid", filter: "stored" }))
await root.ready
expect(root.state.sidebarOpened()).toBeFalse()
expect(root.state.sidebarWidth()).toBe(240)
expect(root.state.expandMode()).toBe("collapse")
expect(root.state.filter()).toBe("transient")
root.dispose()
})
test.each([0, 199, 481, null])("rejects invalid persisted sidebar width %p", async (sidebarWidth) => {
const root = createPanel()
read?.(JSON.stringify({ sidebarWidth, expandMode: "expand" }))
await root.ready
expect(root.state.sidebarWidth()).toBe(240)
expect(root.state.sidebarOpened()).toBeTrue()
expect(root.state.expandMode()).toBe("expand")
root.state.resizeSidebar(1000)
expect(root.state.sidebarWidth()).toBe(480)
root.state.resizeSidebar(0)
expect(root.state.sidebarWidth()).toBe(200)
root.dispose()
})
function createPanel() {
return createRoot((dispose) => {
const state = createReviewPanelState(platform)
const ready = new Promise<void>((resolve) => {
createEffect(() => {
if (state.sidebarTransition()) resolve()
})
})
return { dispose, state, ready }
})
}

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