mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 06:56:21 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b659ca45c | ||
|
|
02c5f32593 |
@@ -417,6 +417,7 @@ 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
|
||||
@@ -479,7 +480,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.workspace }}/packages/cli/dist
|
||||
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -592,8 +592,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1078,9 +1078,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2221,27 +2221,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -87,11 +87,6 @@ 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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PhCLeam/Mx2KL0bu0kg1+TJ46CpEOmnkQQiK3myGqnk=",
|
||||
"aarch64-linux": "sha256-UJ2pj9WQ3hYOJBw0rFwcDx4XrvZf3CIF+hK0A9FjNbc=",
|
||||
"aarch64-darwin": "sha256-WiVvvLEpeWe/LMIgZUBJSkY2jfoxgDmHyddE9kp2Dyg=",
|
||||
"x86_64-darwin": "sha256-67n45FB0BdM30hcipPZqgjPZkfKc1dK56z9kjSLIeFM="
|
||||
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
|
||||
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
|
||||
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
|
||||
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -107,6 +107,8 @@ 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,17 +9,12 @@ 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 },
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
})
|
||||
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
@@ -29,44 +24,22 @@ 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.setAttribute("dir", "rtl")
|
||||
element.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
|
||||
@@ -80,14 +53,11 @@ 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()
|
||||
@@ -118,133 +88,12 @@ 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)
|
||||
@@ -295,7 +144,6 @@ 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()
|
||||
@@ -316,14 +164,13 @@ 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}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
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)
|
||||
@@ -335,17 +182,6 @@ 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[] = []
|
||||
@@ -418,24 +254,6 @@ 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(
|
||||
@@ -474,11 +292,7 @@ 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)
|
||||
@@ -490,5 +304,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, title }
|
||||
return { url, sessionID, messageID, message, shimmer }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
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 } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
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,20 +68,41 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
test("extensions opens without waiting for MCPs or plugins", 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,6 +11,12 @@ beforeAll(async () => {
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
const mod = await import("./comments")
|
||||
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||
})
|
||||
|
||||
@@ -10,9 +10,14 @@ 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 } from "./schema"
|
||||
export type LineComment = {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
time: number
|
||||
}
|
||||
|
||||
type CommentFocus = { file: string; id: string }
|
||||
|
||||
@@ -32,6 +37,10 @@ function decodeSessionKey(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type CommentStore = {
|
||||
comments: Record<string, LineComment[]>
|
||||
}
|
||||
|
||||
function aggregate(comments: Record<string, LineComment[]>) {
|
||||
return Object.keys(comments)
|
||||
.flatMap((file) => comments[file] ?? [])
|
||||
@@ -170,9 +179,12 @@ 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"), CommentStore, {
|
||||
comments: {},
|
||||
})
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "comments"),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
)
|
||||
const session = createCommentSessionState(store, setStore)
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
|
||||
<Show when={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 (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
if (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={view.draftOnly || state.mode === "shell"}
|
||||
disabled={state.mode === "shell"}
|
||||
title={i18n.t("ui.promptInput.add")}
|
||||
keybind={props.attachKeybind ?? ["Mod", "U"]}
|
||||
attachLabel={i18n.t("ui.promptInput.attachments")}
|
||||
|
||||
@@ -29,7 +29,6 @@ export type ComposerSelectControl = {
|
||||
}
|
||||
|
||||
export type ComposerEditorView = {
|
||||
draftOnly?: boolean
|
||||
placeholder?: Accessor<string>
|
||||
add?: {
|
||||
onAttach: () => void
|
||||
@@ -333,7 +332,6 @@ 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())
|
||||
@@ -349,7 +347,6 @@ 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) {
|
||||
@@ -365,7 +362,6 @@ 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,9 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { upgradeHistoryState } from "./store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
@@ -60,11 +58,7 @@ describe("Composer history", () => {
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
|
||||
entries: [text("stored")],
|
||||
}),
|
||||
).toEqual({
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
@@ -8,14 +8,28 @@ 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 = typeof PromptHistoryState.Type
|
||||
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]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function createComposerHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
@@ -37,14 +51,12 @@ function createComposerHistoryStore(
|
||||
|
||||
export function createComposerHistory() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history")),
|
||||
PromptHistoryState,
|
||||
{ entries: [] },
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history-shell")),
|
||||
PromptHistoryState,
|
||||
{ entries: [] },
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
|
||||
@@ -9,15 +9,3 @@ 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 },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
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: {} },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,212 +0,0 @@
|
||||
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) })
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Schema, Option } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createMemoryComposerState, DEFAULT_PROMPT } from "./state"
|
||||
import { ComposerStore } from "./schema"
|
||||
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
@@ -32,9 +29,7 @@ describe("prompt state initialization", () => {
|
||||
})
|
||||
|
||||
test("parses persisted state into one trusted current shape", () => {
|
||||
const parsed = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
const parsed = parseComposerStore({
|
||||
prompt: [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
|
||||
@@ -110,6 +105,6 @@ describe("prompt state initialization", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(Option.isNone(Schema.decodeUnknownOption(ComposerStore)("not an object"))).toBe(true)
|
||||
expect(parseComposerStore("not an object")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,127 @@
|
||||
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"
|
||||
|
||||
export { DEFAULT_PROMPT } from "./schema"
|
||||
export type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
TextPart,
|
||||
} from "./schema"
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -61,14 +147,16 @@ function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
|
||||
}
|
||||
|
||||
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
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` }
|
||||
: {}),
|
||||
})
|
||||
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 }
|
||||
}
|
||||
|
||||
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
@@ -84,6 +172,203 @@ 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)
|
||||
@@ -157,7 +442,11 @@ function createPersistedComposer(
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
target,
|
||||
createStore<ComposerStore>(initialComposerStore(initial)),
|
||||
platform,
|
||||
)
|
||||
return { ready, ...createComposerStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
@@ -171,7 +460,13 @@ export function createComposerState(
|
||||
}
|
||||
|
||||
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedComposer(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||
return createPersistedComposer(
|
||||
{
|
||||
...Persist.prompt(Persist.draft(draftID, "prompt")),
|
||||
migrate: parseComposerStore,
|
||||
},
|
||||
initial,
|
||||
)
|
||||
}
|
||||
|
||||
export type ComposerState = ReturnType<typeof createComposerState>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
@@ -11,36 +10,24 @@ 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()
|
||||
// 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()
|
||||
if (initial !== target) initial.reset()
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
|
||||
retarget(next: ComposerStateTarget) {
|
||||
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: following ? appendPrompt(input.prompt, following) : input.prompt,
|
||||
context: input.context,
|
||||
}
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,6 @@ 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[] = []
|
||||
@@ -248,10 +247,9 @@ 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(),
|
||||
@@ -259,8 +257,6 @@ 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")
|
||||
},
|
||||
}
|
||||
@@ -275,7 +271,6 @@ 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 () => {
|
||||
|
||||
@@ -248,8 +248,7 @@ function restoreSubmission(
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
// A recovered follow-up changes the payload, so it must use a new admission ID.
|
||||
if (value.mode === "normal" && restored.prompt === submission.prompt) {
|
||||
if (value.mode === "normal") {
|
||||
restored.target.retry.set({
|
||||
id: value.id,
|
||||
agent: value.selection.agent,
|
||||
|
||||
@@ -73,7 +73,6 @@ 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,15 +10,10 @@ 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 { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createStore } from "solid-js/store"
|
||||
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()
|
||||
@@ -27,7 +22,10 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerActionsController()
|
||||
const global = useGlobal()
|
||||
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers"),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(promise) => promise.then(() => _state),
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,13 +117,12 @@ export function createNewSessionComposerAdapter(props: {
|
||||
{ dir: base64Encode(sessionDirectory), id: created.id },
|
||||
{ server: server.key, scope: serverSDK.scope },
|
||||
),
|
||||
{ preserveDraft: !!pending },
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
complete: pending ? () => pending.complete(submission.target()) : undefined,
|
||||
complete: pending?.complete,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
@@ -195,7 +194,6 @@ 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,
|
||||
|
||||
@@ -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 { Schema } from "effect"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
@@ -20,19 +20,10 @@ 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
|
||||
@@ -40,8 +31,7 @@ export function NewSessionView(props: {
|
||||
}) {
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
{ used: false },
|
||||
createStore({ used: false }),
|
||||
)
|
||||
const select = (value: string) => {
|
||||
props.workspace.selection.set(value)
|
||||
@@ -120,8 +110,7 @@ function ProviderTip() {
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
ProviderTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
createStore({ dismissedAt: 0 }),
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
|
||||
import {
|
||||
normalizeNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionGit,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
@@ -7,6 +12,8 @@ describe("new session workspace selection", () => {
|
||||
resolveNewSessionWorktree({
|
||||
enabled: false,
|
||||
selected: "/project/feature",
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
}),
|
||||
).toBe("main")
|
||||
})
|
||||
@@ -15,21 +22,31 @@ 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("main")
|
||||
).toBe("/project")
|
||||
})
|
||||
|
||||
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("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("resolves the branch from the active location", () => {
|
||||
|
||||
@@ -11,15 +11,27 @@ 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; fallback?: string }) {
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
fallback?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
return input.fallback ?? "main"
|
||||
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
|
||||
}
|
||||
|
||||
export function resolveNewSessionBranch(input: {
|
||||
@@ -80,6 +92,8 @@ export function createNewSessionWorkspaceController(input: {
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: currentProject()?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
@@ -131,7 +145,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(worktree)
|
||||
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
create: (branch: string) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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"])
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,10 @@ 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"
|
||||
@@ -17,49 +15,39 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
const ModelKeySchema = Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
variant: Schema.optional(Schema.String),
|
||||
})
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
||||
|
||||
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 State = {
|
||||
agent?: string
|
||||
model?: ModelKey
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
type Saved = {
|
||||
session: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -89,9 +77,13 @@ 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"),
|
||||
ModelSelectionSchema,
|
||||
{ session: {} },
|
||||
{
|
||||
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
|
||||
migrate,
|
||||
},
|
||||
createStore<Saved>({
|
||||
session: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import { createMemo, 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 [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 animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
|
||||
(previous) => {
|
||||
const currentIdentity = identity?.()
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
const same = !identity || previous?.identity === currentIdentity
|
||||
return {
|
||||
identity: currentIdentity,
|
||||
show,
|
||||
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
|
||||
value: current ?? (same ? previous?.value : undefined),
|
||||
}
|
||||
},
|
||||
)
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
|
||||
@@ -694,7 +694,6 @@ 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",
|
||||
@@ -745,7 +744,6 @@ 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",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -12,7 +11,6 @@ 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 {
|
||||
@@ -56,16 +54,6 @@ 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 })
|
||||
@@ -160,21 +148,17 @@ function detectLocale(): Locale {
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
|
||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "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 = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
|
||||
if (Option.isNone(next)) return
|
||||
return next.value.locale
|
||||
const next = JSON.parse(raw) as { locale?: string }
|
||||
if (typeof next?.locale !== "string") return
|
||||
return normalizeLocale(next.locale)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -198,9 +182,14 @@ 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"), languageSchema, { locale: initial })
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language"),
|
||||
createStore({
|
||||
locale: initial,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo(() => store.locale)
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
|
||||
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,111 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
export type BlobReference = { id: string; url: string }
|
||||
|
||||
@@ -82,11 +81,7 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const value = await driver.get(key)
|
||||
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))
|
||||
return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
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,6 +107,11 @@ 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")
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ 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 { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
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> = [
|
||||
@@ -24,6 +22,7 @@ type PersistTarget = {
|
||||
workspaceStorageAliases?: string[]
|
||||
previousKey?: string
|
||||
key: string
|
||||
migrate?: (value: unknown) => unknown
|
||||
}
|
||||
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
@@ -165,10 +164,65 @@ function write(storage: Storage, key: string, value: string) {
|
||||
return ok
|
||||
}
|
||||
|
||||
function readCurrent(input: { storage: SyncStorage; key: string; normalize: (raw: string) => string | undefined }) {
|
||||
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
|
||||
}) {
|
||||
const raw = input.storage.getItem(input.key)
|
||||
if (raw === null) return
|
||||
const next = input.normalize(raw)
|
||||
const next = normalize(input.defaults, raw, input.migrate)
|
||||
if (next === undefined) {
|
||||
input.storage.removeItem(input.key)
|
||||
return null
|
||||
@@ -181,14 +235,15 @@ function relocateStoredValue(input: {
|
||||
current: SyncStorage
|
||||
sources: { storage: SyncStorage; key?: string }[]
|
||||
key: string
|
||||
normalize: (raw: string) => string | undefined
|
||||
defaults: unknown
|
||||
migrate?: (value: unknown) => unknown
|
||||
}) {
|
||||
for (const source of input.sources) {
|
||||
const key = source.key ?? input.key
|
||||
const raw = source.storage.getItem(key)
|
||||
if (raw === null) continue
|
||||
|
||||
const next = input.normalize(raw)
|
||||
const next = normalize(input.defaults, raw, input.migrate)
|
||||
if (next === undefined) {
|
||||
source.storage.removeItem(key)
|
||||
continue
|
||||
@@ -204,11 +259,12 @@ function relocateStoredValue(input: {
|
||||
async function readCurrentAsync(input: {
|
||||
storage: AsyncStorage
|
||||
key: string
|
||||
normalize: (raw: string) => string | undefined
|
||||
defaults: unknown
|
||||
migrate?: (value: unknown) => unknown
|
||||
}) {
|
||||
const raw = await input.storage.getItem(input.key)
|
||||
if (raw === null) return
|
||||
const next = input.normalize(raw)
|
||||
const next = normalize(input.defaults, raw, input.migrate)
|
||||
if (next === undefined) {
|
||||
await input.storage.removeItem(input.key).catch(() => undefined)
|
||||
return null
|
||||
@@ -235,14 +291,15 @@ async function relocateStoredValueAsync(input: {
|
||||
current: AsyncStorage
|
||||
sources: { storage: AsyncStorage; key?: string }[]
|
||||
key: string
|
||||
normalize: (raw: string) => string | undefined
|
||||
defaults: unknown
|
||||
migrate?: (value: unknown) => unknown
|
||||
}) {
|
||||
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 = input.normalize(raw)
|
||||
const next = normalize(input.defaults, raw, input.migrate)
|
||||
if (next === undefined) {
|
||||
await removeAsync(source.storage, key)
|
||||
continue
|
||||
@@ -389,6 +446,7 @@ export function draftPersistedKeys() {
|
||||
export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
normalize,
|
||||
resolveTarget,
|
||||
windowStorage,
|
||||
workspaceStorage,
|
||||
@@ -471,24 +529,15 @@ export function removePersisted(
|
||||
}
|
||||
}
|
||||
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
export function persisted<T>(
|
||||
target: string | PersistTarget,
|
||||
schema: S | Persistence.Migrated<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
store: [Store<T>, SetStoreFunction<T>],
|
||||
platformOverride?: Platform,
|
||||
): PersistedWithReady<S["Type"]> {
|
||||
): PersistedWithReady<T> {
|
||||
const platform = platformOverride ?? usePlatform()
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
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 defaults = snapshot(store[0])
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
|
||||
@@ -518,13 +567,14 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
|
||||
const api: SyncStorage = {
|
||||
getItem: (key) => {
|
||||
const value = readCurrent({ storage: current, key, normalize })
|
||||
const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
|
||||
if (value !== undefined) return value
|
||||
return relocateStoredValue({
|
||||
current,
|
||||
sources,
|
||||
key,
|
||||
normalize,
|
||||
defaults,
|
||||
migrate: config.migrate,
|
||||
})
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
@@ -560,13 +610,14 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
const value = await readCurrentAsync({ storage: current, key, normalize })
|
||||
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
|
||||
if (value !== undefined) return value
|
||||
const relocated = await relocateStoredValueAsync({
|
||||
current,
|
||||
sources: relocationSources,
|
||||
key,
|
||||
normalize,
|
||||
defaults,
|
||||
migrate: config.migrate,
|
||||
})
|
||||
if (draftLatest === undefined) {
|
||||
if (draft && relocated !== null) return (await current.getItem(key)) ?? relocated
|
||||
@@ -593,11 +644,9 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
|
||||
const [state, setState, init] = makePersisted<T, 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 persisted = (_target, _schema, initial) => [
|
||||
...createStore(initial),
|
||||
const persist: typeof import("@/runtime/persistence/storage").persisted = (_target, store) => [
|
||||
store[0],
|
||||
store[1],
|
||||
null,
|
||||
Object.assign(() => true, { promise: undefined }),
|
||||
]
|
||||
|
||||
@@ -20,7 +20,6 @@ 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
|
||||
@@ -156,20 +155,29 @@ 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"), VcsState, { value: undefined }),
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "vcs"),
|
||||
createStore({ value: undefined as VcsInfo | 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"), ProjectState, { value: undefined }),
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "project"),
|
||||
createStore({ value: undefined as ProjectMeta | 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"), IconState, { value: undefined }),
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "icon"),
|
||||
createStore({ value: undefined as string | undefined }),
|
||||
),
|
||||
)
|
||||
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
||||
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
@@ -3,9 +3,17 @@ 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 = NonNullable<typeof ProjectState.Type.value>
|
||||
export type ProjectMeta = {
|
||||
name?: string
|
||||
icon?: {
|
||||
override?: string
|
||||
color?: string
|
||||
}
|
||||
commands?: {
|
||||
start?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
@@ -32,20 +40,20 @@ export type State = {
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
store: Store<typeof VcsState.Type>
|
||||
setStore: SetStoreFunction<typeof VcsState.Type>
|
||||
store: Store<{ value: VcsInfo | undefined }>
|
||||
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type MetaCache = {
|
||||
store: Store<typeof ProjectState.Type>
|
||||
setStore: SetStoreFunction<typeof ProjectState.Type>
|
||||
store: Store<{ value: ProjectMeta | undefined }>
|
||||
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type IconCache = {
|
||||
store: Store<typeof IconState.Type>
|
||||
setStore: SetStoreFunction<typeof IconState.Type>
|
||||
store: Store<{ value: string | undefined }>
|
||||
setStore: SetStoreFunction<{ value: string | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
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,25 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import {
|
||||
migrateCanonicalLocalServerState,
|
||||
migrateServerAuthState,
|
||||
resolveServerList,
|
||||
ServerConnection,
|
||||
} from "./registry"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
function serverSchema() {
|
||||
return Persistence.withInitial(serverState(), {
|
||||
list: [],
|
||||
hidden: {},
|
||||
projects: {},
|
||||
lastProject: {},
|
||||
recentlyClosed: {},
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
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: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
|
||||
stored: [{ url: "https://server.example.test" }],
|
||||
props: [
|
||||
{
|
||||
type: "http",
|
||||
@@ -39,14 +64,17 @@ describe("resolveServerList", () => {
|
||||
password: "secret",
|
||||
})
|
||||
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
|
||||
expect(list[0] && String(ServerConnection.key(list[0]))).toBe("https://server.example.test")
|
||||
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
|
||||
})
|
||||
|
||||
test("keeps persisted credentials when startup has no auth_token", () => {
|
||||
const list = resolveServerList({
|
||||
stored: Schema.decodeUnknownSync(serverSchema())({
|
||||
list: [{ url: "https://server.example.test", password: "saved" }],
|
||||
}).list,
|
||||
stored: [
|
||||
{
|
||||
url: "https://server.example.test",
|
||||
password: "saved",
|
||||
},
|
||||
],
|
||||
props: [{ type: "http", http: { url: "https://server.example.test" } }],
|
||||
})
|
||||
|
||||
@@ -76,42 +104,47 @@ test("treats WSL sidecars as remote server connections", () => {
|
||||
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
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("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),
|
||||
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" },
|
||||
})
|
||||
})
|
||||
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 }])
|
||||
})
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { createStore, 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 ServerState = ReturnType<typeof serverState>["current"]["Type"]
|
||||
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
|
||||
// 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
|
||||
@@ -32,12 +38,65 @@ function isLocalHost(url: string) {
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
}
|
||||
|
||||
export function createServerProjects(input: {
|
||||
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: {
|
||||
scope: () => ServerScope
|
||||
store: Store<ServerState>
|
||||
setStore: SetStoreFunction<ServerState>
|
||||
store: Store<T>
|
||||
setStore: SetStoreFunction<T>
|
||||
}) {
|
||||
const setStore = input.setStore
|
||||
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||
const current = () => input.store.projects[input.scope()] ?? []
|
||||
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
||||
const remove = (directory: string) => {
|
||||
@@ -103,13 +162,22 @@ export function createServerProjects(input: {
|
||||
|
||||
export function resolveServerList(input: {
|
||||
props?: Array<ServerConnection.Any>
|
||||
stored: ServerConnection.Http[]
|
||||
stored: StoredServer[]
|
||||
}): Array<ServerConnection.Any> {
|
||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
|
||||
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
|
||||
)
|
||||
|
||||
for (const conn of input.stored) {
|
||||
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 }
|
||||
const key = ServerConnection.key(conn)
|
||||
|
||||
const existing = deduped.get(key)
|
||||
@@ -128,19 +196,28 @@ export function resolveServerList(input: {
|
||||
export function canRemoveServer(input: {
|
||||
key: ServerConnection.Key
|
||||
provided?: Array<ServerConnection.Any>
|
||||
stored: ServerConnection.Http[]
|
||||
stored: StoredServer[]
|
||||
}) {
|
||||
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
|
||||
return input.stored.some((server) => server.http.url === input.key)
|
||||
return input.stored.some((server) =>
|
||||
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
|
||||
)
|
||||
}
|
||||
|
||||
export namespace ServerConnection {
|
||||
type Base = { displayName?: string; label?: string }
|
||||
|
||||
export type HttpBase = typeof ServerHttpBase.Type
|
||||
export type HttpBase = {
|
||||
url: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
// Regular web connections
|
||||
export type Http = typeof ServerHttp.Type
|
||||
export type Http = {
|
||||
type: "http"
|
||||
http: HttpBase
|
||||
authToken?: boolean
|
||||
} & Base
|
||||
|
||||
export type Sidecar = {
|
||||
type: "sidecar"
|
||||
@@ -182,8 +259,8 @@ export namespace ServerConnection {
|
||||
}
|
||||
}
|
||||
|
||||
export const Key = ServerKey
|
||||
export type Key = typeof Key.Type
|
||||
export type Key = string & { _brand: "Key" }
|
||||
export const Key = { make: (v: string) => v as Key }
|
||||
|
||||
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
|
||||
export const local = (conn?: Any) =>
|
||||
@@ -203,11 +280,19 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
...Persist.global("server"),
|
||||
sync: true,
|
||||
previousKey: "server.v3",
|
||||
migrate: (value) => migrateCanonicalLocalServerState(migrateServerAuthState(value), props.canonicalLocalServer),
|
||||
},
|
||||
serverState(() => props.canonicalLocalServer),
|
||||
{ list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} },
|
||||
createStore({
|
||||
list: [] as StoredServer[],
|
||||
hidden: {} as Record<string, boolean>,
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
@@ -218,7 +303,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) => x.http.url === url_)
|
||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||
if (existing !== -1) {
|
||||
setStore("list", existing, conn)
|
||||
} else {
|
||||
@@ -229,7 +314,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const list = store.list.filter((x) => x.http.url !== key)
|
||||
const list = store.list.filter((x) => url(x) !== key)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ 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",
|
||||
@@ -99,11 +98,18 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
})
|
||||
|
||||
function createGlobalModels() {
|
||||
const [store, setStore, _, ready] = persisted(Persist.global("model"), ModelState, {
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
})
|
||||
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 [recent] = createResource(
|
||||
async () => {
|
||||
const value = store.recent
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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, on, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -219,6 +220,14 @@ 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,
|
||||
@@ -247,12 +256,26 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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,8 +5,6 @@ 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",
|
||||
@@ -28,10 +26,6 @@ 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",
|
||||
@@ -169,7 +163,7 @@ export function useOpenInApp(input: { directory: () => string }) {
|
||||
] as const
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), OpenAppPreferences, { app: "finder" })
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
app: undefined as OpenApp | undefined,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -5,24 +5,18 @@ import {
|
||||
type SessionReviewExpandMode,
|
||||
} from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { createStore } from "solid-js/store"
|
||||
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"),
|
||||
ReviewPanel,
|
||||
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
|
||||
createStore({
|
||||
sidebarOpened: true,
|
||||
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
|
||||
expandMode: "collapse" as SessionReviewExpandMode,
|
||||
}),
|
||||
platform,
|
||||
)
|
||||
// The filter is transient by design: a persisted filter would silently hide
|
||||
|
||||
@@ -26,7 +26,6 @@ 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 }>()
|
||||
@@ -65,8 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pt-5 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
sessionID={props.sessionID}
|
||||
message={props.pending.message}
|
||||
@@ -86,7 +84,6 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PreparingComposer pending={props.pending} />
|
||||
</DataProvider>
|
||||
</SessionStatePanel>
|
||||
)
|
||||
@@ -119,7 +116,7 @@ function ResolvedTargetSessionRoute() {
|
||||
const current = createSessionResolution(
|
||||
() => params.id,
|
||||
() => data.session,
|
||||
{ children: true, connected: () => server.ctx.sdk.connection.status() === "connected" },
|
||||
{ children: true },
|
||||
)
|
||||
const directory = createMemo(() => current()?.location.directory)
|
||||
|
||||
@@ -136,7 +133,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value} workspaceID={() => current()?.location.workspaceID}>
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -28,8 +27,6 @@ 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(() =>
|
||||
@@ -57,18 +54,10 @@ 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 ?? pending()?.draft.directory ?? info()?.directory,
|
||||
)
|
||||
const title = createMemo(() =>
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
|
||||
const title = createMemo(() => 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)
|
||||
@@ -81,7 +70,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(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
const workspaceSession = createMemo(() => 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; connected?: () => boolean },
|
||||
options?: { children?: 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, () => options?.connected?.() ?? true] as const, ([id, store, connected]) => {
|
||||
if (!id || !connected) return
|
||||
on([sessionID, sessions] as const, ([id, store]) => {
|
||||
if (!id) 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 && !options?.connected) {
|
||||
if (cached() && !options?.children) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@ 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 decodeTerminalState: (value: unknown) => unknown
|
||||
let roundTripTerminalState: (value: unknown) => unknown
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
@@ -18,13 +15,16 @@ 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
|
||||
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
|
||||
decodeTerminalState = Schema.decodeUnknownSync(schema)
|
||||
roundTripTerminalState = (value) =>
|
||||
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -61,10 +61,10 @@ describe("getWorkspaceTerminalCacheKey", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("TerminalState", () => {
|
||||
describe("migrateTerminalState", () => {
|
||||
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||
expect(
|
||||
decodeTerminalState({
|
||||
migrateTerminalState({
|
||||
active: "missing",
|
||||
all: [
|
||||
null,
|
||||
@@ -85,7 +85,7 @@ describe("TerminalState", () => {
|
||||
|
||||
test("keeps a valid active id", () => {
|
||||
expect(
|
||||
decodeTerminalState({
|
||||
migrateTerminalState({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1" },
|
||||
@@ -100,44 +100,4 @@ describe("TerminalState", () => {
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,51 +8,81 @@ 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"
|
||||
|
||||
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
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const State = Persistence.struct({
|
||||
active: Persistence.optional(Schema.String),
|
||||
all: Persistence.array(PTY),
|
||||
})
|
||||
function pty(value: unknown): LocalPTY | undefined {
|
||||
if (!record(value)) return
|
||||
|
||||
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),
|
||||
}),
|
||||
)
|
||||
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 function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
|
||||
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
|
||||
@@ -101,7 +131,18 @@ function createWorkspaceTerminalSession(
|
||||
) {
|
||||
const location = { directory: sdk.directory }
|
||||
|
||||
const [store, setStore, _, ready] = persisted(terminalPersistTarget(scope, dir), TerminalState, { all: [] })
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...terminalPersistTarget(scope, dir),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}>({
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
const [ui, setUi] = createStore({
|
||||
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
|
||||
})
|
||||
|
||||
@@ -539,96 +539,33 @@ function MessageTimelineView(
|
||||
.findLast((ref) => blocking.has(ref.partID))?.partID
|
||||
})
|
||||
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<>
|
||||
<Show when={showWorking()}>
|
||||
<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
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
[`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(),
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
deferred={(row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
|
||||
@@ -60,7 +60,6 @@ export function SessionWorkspaceMenu(props: {
|
||||
selection === "create"
|
||||
? await createWorktree({
|
||||
api: sdk.api,
|
||||
data,
|
||||
directory: props.directory,
|
||||
project: data.location.info({ directory: props.directory })?.project,
|
||||
})
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,8 +2,7 @@ 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 { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { useDialog, useI18n } from "@opencode-ai/ui/context"
|
||||
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
|
||||
@@ -15,13 +14,6 @@ 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
|
||||
@@ -47,12 +39,15 @@ export function useUsageExceededDialogs() {
|
||||
const { t, locale } = useI18n()
|
||||
const isEnglish = () => locale() === "en"
|
||||
|
||||
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,
|
||||
})
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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)
|
||||
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings reasoning mode migration", () => {
|
||||
test.each([
|
||||
[true, "full"],
|
||||
[false, "compact"],
|
||||
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
|
||||
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(migrateSettings(value)).toEqual({
|
||||
...value,
|
||||
general: { ...value.general, reasoningMode },
|
||||
})
|
||||
expect(value.general).not.toHaveProperty("reasoningMode")
|
||||
})
|
||||
|
||||
@@ -35,151 +19,13 @@ describe("settings reasoning mode migration", () => {
|
||||
(reasoningMode) => {
|
||||
;[true, false].forEach((showReasoningSummaries) => {
|
||||
const value = { general: { reasoningMode, showReasoningSummaries } }
|
||||
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
|
||||
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()
|
||||
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
|
||||
expect(migrateSettings(value)).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,20 +1,68 @@
|
||||
import { reconcile } from "solid-js/store"
|
||||
import { createStore, 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 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 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 const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
@@ -68,99 +116,7 @@ export function terminalFontFamily(font: string | undefined) {
|
||||
return stack(font, terminalBase)
|
||||
}
|
||||
|
||||
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 = {
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
@@ -179,11 +135,26 @@ export 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",
|
||||
@@ -198,11 +169,26 @@ 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" }, settingsPersistence, defaultSettings)
|
||||
const [store, setStore, , ready] = persisted(
|
||||
{ key: "settings.v3", migrate: migrateSettings },
|
||||
createStore<Settings>(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)
|
||||
|
||||
@@ -5,8 +5,7 @@ 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 { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -21,18 +20,13 @@ 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"),
|
||||
ModelProvidersSchema,
|
||||
{ collapsed: {} },
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
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" },
|
||||
|
||||
@@ -2,8 +2,6 @@ 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"
|
||||
@@ -102,17 +100,14 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
|
||||
|
||||
type CommandSource = "palette" | "keybind" | "slash"
|
||||
|
||||
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 CommandCatalogItem = {
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type CommandRegistration = {
|
||||
key?: string
|
||||
@@ -273,7 +268,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(Persist.global("command.catalog.v1"), CommandCatalog, {})
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
const custom = settings.keybinds.get(actionId(id))
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
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 { 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)
|
||||
})
|
||||
import { openNotificationSession } from "./notification"
|
||||
|
||||
test("opens notification sessions through the tab router", () => {
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
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"
|
||||
@@ -19,19 +17,24 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const NotificationBase = {
|
||||
directory: Schema.optional(Schema.String),
|
||||
session: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Unknown),
|
||||
time: Schema.Finite,
|
||||
viewed: Schema.Boolean,
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
session?: string
|
||||
metadata?: unknown
|
||||
time: number
|
||||
viewed: boolean
|
||||
}
|
||||
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 TurnCompleteNotification = NotificationBase & {
|
||||
type: "turn-complete"
|
||||
}
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
|
||||
type NotificationIndex = {
|
||||
session: {
|
||||
@@ -50,7 +53,11 @@ 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)
|
||||
@@ -123,8 +130,9 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(input.sdk.scope, "notification"),
|
||||
NotificationStore,
|
||||
{ list: [] },
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
@@ -222,7 +230,10 @@ 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}`)
|
||||
}
|
||||
|
||||
@@ -242,12 +253,20 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (sessionID: string, error: SessionError.Error, eventID: string, time: number) => {
|
||||
const handleSessionError = (
|
||||
sessionID: string,
|
||||
error: ErrorNotification["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,105 +1,7 @@
|
||||
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[] = []
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -8,8 +7,6 @@ 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"
|
||||
@@ -47,11 +44,20 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||
return "gray"
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
reviewMode?: ReviewChangeMode
|
||||
reviewFile?: string
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
}
|
||||
|
||||
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
|
||||
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
export type ReviewChangeMode = "git" | "branch" | "turn"
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
export type TabPanes = {
|
||||
terminalOpened: Accessor<boolean>
|
||||
@@ -127,114 +133,6 @@ 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,
|
||||
@@ -242,10 +140,137 @@ 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" },
|
||||
layoutPersistence,
|
||||
initialLayout(ServerConnection.key(servers.list[0])),
|
||||
{ ...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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
import type { TabStorage } from "./schema"
|
||||
|
||||
export type ClosedTab = typeof TabStorage.ClosedTab.Type
|
||||
export type ClosedTab = {
|
||||
tab: SessionTab
|
||||
index: number
|
||||
}
|
||||
|
||||
const CLOSED_TAB_LIMIT = 25
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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 []
|
||||
})
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
@@ -3,13 +3,10 @@ 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 { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
import { migrateTabs } from "./migration"
|
||||
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 }
|
||||
@@ -18,66 +15,27 @@ function sessionTab(sessionId: string): SessionTab {
|
||||
describe("tab migration", () => {
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
expect(
|
||||
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
|
||||
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
|
||||
).toEqual([sessionTab("a")])
|
||||
})
|
||||
|
||||
test("drops persisted tabs without a server", () => {
|
||||
expect(decodeTabs([{ type: "session", sessionId: "a" }])).toEqual([])
|
||||
expect(migrateTabs([{ type: "session", sessionId: "a" }])).toEqual([])
|
||||
})
|
||||
|
||||
test("replaces invalid top-level persisted data", () => {
|
||||
expect(decodeTabs(null)).toEqual([])
|
||||
expect(decodeTabs({})).toEqual([])
|
||||
expect(migrateTabs(null)).toEqual([])
|
||||
expect(migrateTabs({})).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves the active child route", () => {
|
||||
expect(decodeTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
|
||||
expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
|
||||
{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" },
|
||||
])
|
||||
})
|
||||
|
||||
test("drops an invalid child route", () => {
|
||||
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()
|
||||
expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,31 +12,47 @@ 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,
|
||||
createMemoryComposerState,
|
||||
type ComposerState,
|
||||
type PromptModel,
|
||||
} from "@/composer/state"
|
||||
import { appendPrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import { TabStorage } from "./schema"
|
||||
import { createDraftComposerState, type PromptModel } from "@/composer/state"
|
||||
import { migrateTabs } from "./migration"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
|
||||
export type SessionTab = typeof TabStorage.Session.Type
|
||||
export type DraftTab = typeof TabStorage.Draft.Type
|
||||
export type Tab = typeof TabStorage.Tab.Type
|
||||
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 PendingSession = {
|
||||
draft: DraftTab
|
||||
message: SessionMessageUser
|
||||
selection: ComposerSelection
|
||||
composer: ComposerState
|
||||
}
|
||||
|
||||
export type TabInfo = typeof TabStorage.Info.Type
|
||||
export type TabInfo = {
|
||||
title?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
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)}`
|
||||
|
||||
@@ -69,13 +85,23 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
init: () => {
|
||||
const servers = useServers()
|
||||
const platform = usePlatform()
|
||||
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 [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 [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
|
||||
|
||||
const params = useParams()
|
||||
@@ -292,9 +318,8 @@ 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, composer })
|
||||
setPending(key, { draft, ...preview })
|
||||
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
|
||||
@@ -309,11 +334,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
return {
|
||||
ready,
|
||||
async complete(target: ReturnType<ComposerState["capture"]>) {
|
||||
async complete() {
|
||||
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)
|
||||
@@ -321,12 +344,6 @@ 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) {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -26,7 +25,6 @@ export function TabNavItem(props: {
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -241,14 +239,7 @@ export function TabNavItem(props: {
|
||||
when={props.session}
|
||||
keyed
|
||||
fallback={
|
||||
<Show
|
||||
when={props.preparing}
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
</Show>
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{(session) => (
|
||||
|
||||
@@ -27,7 +27,6 @@ function SessionTabSlot(props: {
|
||||
active: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -63,7 +62,6 @@ 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)}
|
||||
@@ -169,7 +167,6 @@ function SessionTabEntry(props: {
|
||||
active={props.active}
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
preparing={!!pending()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
|
||||
@@ -28,7 +28,6 @@ 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"
|
||||
|
||||
@@ -172,15 +171,11 @@ 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 (preparing()) return undefined
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
|
||||
},
|
||||
@@ -189,7 +184,7 @@ export function Titlebar(props: {
|
||||
const session = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return
|
||||
if (preparing()) return
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) 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
|
||||
@@ -446,17 +441,10 @@ export function Titlebar(props: {
|
||||
tab().type === "draft" ? (
|
||||
<Icon name="edit" />
|
||||
) : (
|
||||
<Show
|
||||
when={preparing()}
|
||||
fallback={
|
||||
<span
|
||||
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
</Show>
|
||||
<span
|
||||
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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,19 +1,17 @@
|
||||
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"
|
||||
|
||||
export const HighlightsStore = Persistence.struct({
|
||||
version: Schema.UndefinedOr(Schema.String),
|
||||
})
|
||||
type Store = {
|
||||
version?: string
|
||||
}
|
||||
|
||||
type ParsedRelease = {
|
||||
tag?: string
|
||||
@@ -146,7 +144,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", HighlightsStore, { version: undefined })
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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", () => {
|
||||
@@ -46,44 +44,28 @@ describe("worktree creation", () => {
|
||||
),
|
||||
})
|
||||
|
||||
await createRoot(async (dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
expect(
|
||||
await createWorktree({
|
||||
api,
|
||||
directory: input.directory,
|
||||
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()
|
||||
}
|
||||
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"])
|
||||
})
|
||||
|
||||
test("does not fall back to a shared project when location lookup fails", async () => {
|
||||
@@ -99,21 +81,10 @@ describe("worktree creation", () => {
|
||||
),
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status: 503 },
|
||||
})
|
||||
expect(requests.map((request) => request.method)).toEqual(["GET"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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
|
||||
@@ -17,7 +15,6 @@ export async function createWorktree(input: {
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await input.data.location.syncInfo({ directory: created.directory })
|
||||
await input.api.location.get({ location: { directory: created.directory } })
|
||||
return created.directory
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
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 FileSelection = {
|
||||
startLine: number
|
||||
startChar: number
|
||||
endLine: number
|
||||
endChar: number
|
||||
}
|
||||
|
||||
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 SelectedLineRange = {
|
||||
start: number
|
||||
end: number
|
||||
side?: "additions" | "deletions"
|
||||
endSide?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export type FileViewState = {
|
||||
scrollTop?: number
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import { SelectedLineRange } from "./types"
|
||||
import type { FileViewState, 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 }
|
||||
|
||||
@@ -47,9 +35,14 @@ 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"), FileViewsSchema, {
|
||||
file: {},
|
||||
})
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "file-view"),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
file: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const meta = { pruned: false }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
@@ -9,6 +8,7 @@ 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,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
|
||||
},
|
||||
)
|
||||
const ref = createMemo(() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.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()
|
||||
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 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 })
|
||||
})
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
@@ -46,6 +46,7 @@ const context = createSimpleContext({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
error: error(),
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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 { Schema } from "effect"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
@@ -34,96 +34,12 @@ 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" },
|
||||
Schema.Struct({ list: Schema.mutable(Schema.Array(Schema.String)) }),
|
||||
{ list: [] },
|
||||
createStore({ list: [] as string[] }),
|
||||
platform,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,22 +70,4 @@ 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,18 +19,6 @@ 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,41 +66,3 @@ 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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { TabStorage } from "@/shell/tabs/schema"
|
||||
|
||||
const Current = Schema.Struct({
|
||||
enabled: Schema.Boolean,
|
||||
label: Schema.String,
|
||||
})
|
||||
const initial = { enabled: true, label: "default" }
|
||||
const Stored = Persistence.migrate(
|
||||
Current,
|
||||
Schema.Struct({ oldLabel: Schema.optional(Schema.String), label: Schema.optional(Schema.String) }).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) =>
|
||||
value.oldLabel === undefined ? value : { ...value, label: value.oldLabel },
|
||||
),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const web: Platform = {
|
||||
platform: "web",
|
||||
openExternal: () => undefined,
|
||||
restart: async () => undefined,
|
||||
notify: async () => undefined,
|
||||
}
|
||||
|
||||
function desktop() {
|
||||
const values = new Map<string, string>()
|
||||
const platform: Platform = {
|
||||
...web,
|
||||
platform: "desktop",
|
||||
windowID: "schema-test",
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
storage: (name) => ({
|
||||
getItem: async (key) => values.get(`${name}:${key}`) ?? null,
|
||||
setItem: async (key, value) => void values.set(`${name}:${key}`, value),
|
||||
removeItem: async (key) => void values.delete(`${name}:${key}`),
|
||||
}),
|
||||
}
|
||||
return { values, platform }
|
||||
}
|
||||
|
||||
describe("schema-backed persistence", () => {
|
||||
test("clears the recent tab after restoring it from storage", () => {
|
||||
const target = Persist.global("schema-recent-clear")
|
||||
const key = `${target.storage}:${target.key}`
|
||||
localStorage.setItem(key, JSON.stringify({ key: "session-tab" }))
|
||||
createRoot((dispose) => {
|
||||
const [state, setState] = persisted(target, TabStorage.Recent, { key: undefined }, web)
|
||||
try {
|
||||
expect(state.key).toBe("session-tab")
|
||||
setState("key", undefined)
|
||||
expect(state.key).toBeUndefined()
|
||||
expect(localStorage.getItem(key)).toBe("{}")
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates sync storage and writes only the current representation", () => {
|
||||
const target = Persist.global("schema-sync")
|
||||
const key = `${target.storage}:${target.key}`
|
||||
localStorage.setItem(key, JSON.stringify({ oldLabel: "saved" }))
|
||||
createRoot((dispose) => {
|
||||
const [state, setState, , ready] = persisted(target, Stored, initial, web)
|
||||
expect(ready.promise).toBeUndefined()
|
||||
expect(state).toEqual({ enabled: true, label: "saved" })
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
|
||||
setState("enabled", false)
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("recovers invalid fields and strips fields outside the schema", () => {
|
||||
const target = Persist.global("schema-invalid-field")
|
||||
localStorage.setItem(
|
||||
`${target.storage}:${target.key}`,
|
||||
JSON.stringify({ enabled: "false", label: "kept", extra: 1 }),
|
||||
)
|
||||
createRoot((dispose) => {
|
||||
const [state] = persisted(target, Stored, initial, web)
|
||||
expect(state).toEqual({ enabled: true, label: "kept" })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("malformed JSON falls back to a typed initial state", () => {
|
||||
const target = Persist.global("schema-invalid-json")
|
||||
localStorage.setItem(`${target.storage}:${target.key}`, '{"label":"\\x"}')
|
||||
createRoot((dispose) => {
|
||||
const [state] = persisted(target, Stored, { enabled: false, label: "initial" }, web)
|
||||
expect(state).toEqual({ enabled: false, label: "initial" })
|
||||
expect(localStorage.getItem(`${target.storage}:${target.key}`)).toBeNull()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("relocates and canonicalizes desktop state before becoming ready", async () => {
|
||||
const storage = desktop()
|
||||
storage.values.set("undefined:old-schema", JSON.stringify({ oldLabel: "desktop" }))
|
||||
const root = createRoot((dispose) => ({
|
||||
dispose,
|
||||
state: persisted(
|
||||
{ ...Persist.global("schema-desktop"), previousKey: "old-schema" },
|
||||
Stored,
|
||||
initial,
|
||||
storage.platform,
|
||||
),
|
||||
}))
|
||||
try {
|
||||
expect(root.state[3]()).toBe(false)
|
||||
await root.state[3].promise
|
||||
expect(root.state[0]).toEqual({ enabled: true, label: "desktop" })
|
||||
expect(storage.values.has("undefined:old-schema")).toBe(false)
|
||||
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
|
||||
enabled: true,
|
||||
label: "desktop",
|
||||
})
|
||||
root.state[1]("label", "changed")
|
||||
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
|
||||
enabled: true,
|
||||
label: "changed",
|
||||
})
|
||||
} finally {
|
||||
root.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a late desktop read does not overwrite an edit made while loading", async () => {
|
||||
const pending = Promise.withResolvers<string | null>()
|
||||
const storage = desktop()
|
||||
storage.platform.storage = () => ({
|
||||
getItem: () => pending.promise,
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
})
|
||||
const root = createRoot((dispose) => ({
|
||||
dispose,
|
||||
state: persisted(Persist.global("schema-late"), Stored, initial, storage.platform),
|
||||
}))
|
||||
try {
|
||||
root.state[1]("label", "new edit")
|
||||
pending.resolve(JSON.stringify({ oldLabel: "old state" }))
|
||||
await root.state[3].promise
|
||||
expect(root.state[0].label).toBe("new edit")
|
||||
} finally {
|
||||
root.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("cross-window updates use the same migration and validation boundary", async () => {
|
||||
const target = { ...Persist.global("schema-sync-channel"), sync: true }
|
||||
const channel = new BroadcastChannel(`opencode.persist:${target.storage}:${target.key}`)
|
||||
const received = Promise.withResolvers<void>()
|
||||
const values: unknown[] = []
|
||||
const root = createRoot((dispose) => {
|
||||
const [state] = persisted(target, Stored, initial, web)
|
||||
createComputed(() => {
|
||||
values.push({ enabled: state.enabled, label: state.label })
|
||||
if (state.label === "from another window") received.resolve()
|
||||
})
|
||||
return { dispose, state }
|
||||
})
|
||||
try {
|
||||
channel.postMessage({ key: target.key, newValue: JSON.stringify({ enabled: "false", label: "recovered" }) })
|
||||
channel.postMessage({ key: target.key, newValue: JSON.stringify({ oldLabel: "from another window" }) })
|
||||
await received.promise
|
||||
expect(root.state).toEqual({ enabled: true, label: "from another window" })
|
||||
expect(values).toContainEqual({ enabled: true, label: "recovered" })
|
||||
expect(values).toContainEqual({ enabled: true, label: "from another window" })
|
||||
} finally {
|
||||
channel.close()
|
||||
root.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSessionResolution } from "@/session/session-resolution"
|
||||
|
||||
type Session = { id: string; directory: string }
|
||||
@@ -35,10 +34,9 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
},
|
||||
},
|
||||
},
|
||||
settle(id: string, directory = `/dir/${id}`) {
|
||||
setCache({ ...cache(), [id]: { id, directory } })
|
||||
settle(id: string) {
|
||||
setCache({ ...cache(), [id]: sessionOf(id) })
|
||||
deferred.get(id)?.resolve(undefined)
|
||||
deferred.delete(id)
|
||||
},
|
||||
fail(id: string, error: unknown) {
|
||||
deferred.get(id)?.reject(error)
|
||||
@@ -61,38 +59,6 @@ const flush = async () => {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
test("refreshes the current session on reconnect while keeping cached content visible", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture({ ses_a: sessionOf("ses_a") })
|
||||
const [connection, setConnection] = createStore({ connected: false })
|
||||
const current = createSessionResolution(
|
||||
() => "ses_a",
|
||||
() => fixture.sessions,
|
||||
{ connected: () => connection.connected },
|
||||
)
|
||||
|
||||
expect(current()).toEqual(sessionOf("ses_a"))
|
||||
expect(fixture.resolves).toEqual([])
|
||||
await flush()
|
||||
setConnection("connected", true)
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
|
||||
setConnection("connected", false)
|
||||
expect(current()).toEqual(sessionOf("ses_a"))
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
setConnection("connected", true)
|
||||
expect(fixture.resolves).toEqual(["ses_a", "ses_a"])
|
||||
expect(current()).toEqual(sessionOf("ses_a"))
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"])
|
||||
fixture.settle("ses_a", "/worktrees/moved")
|
||||
await flush()
|
||||
expect(current()?.directory).toBe("/worktrees/moved")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("starts metadata and messages in parallel once the route has a session ID", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user